gemstar 0.0.2 → 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,15 @@
1
+ require "fileutils"
2
+
3
+ module Gemstar
4
+ module Config
5
+ module_function
6
+
7
+ def home_directory
8
+ File.expand_path("~/.config/gemstar")
9
+ end
10
+
11
+ def ensure_home_directory!
12
+ FileUtils.mkdir_p(home_directory)
13
+ end
14
+ end
15
+ end
@@ -1,25 +1,36 @@
1
+ require "open3"
2
+ require "pathname"
3
+
1
4
  module Gemstar
2
5
  class GitRepo
6
+ attr_reader :tree_root_directory
7
+
3
8
  def initialize(specified_directory)
4
9
  @specified_directory = specified_directory || Dir.pwd
5
- @tree_root_directory = find_git_root(File.dirname(@specified_directory))
10
+ search_directory = if File.directory?(@specified_directory)
11
+ @specified_directory
12
+ else
13
+ File.dirname(@specified_directory)
14
+ end
15
+ @tree_root_directory = find_git_root(search_directory)
6
16
  end
7
17
 
8
18
  def find_git_root(directory)
9
- # return directory if File.directory?(File.join(directory, ".git"))
10
- # find_git_root(File.dirname(directory))
11
-
12
- run_git_command(%W[rev-parse --show-toplevel])
19
+ try_git_command(%W[rev-parse --show-toplevel], in_directory: directory)
13
20
  end
14
21
 
15
22
  def git_client
16
23
  "git"
17
24
  end
18
25
 
19
- def run_git_command(command, in_directory: @specified_directory, strip: true)
26
+ def build_git_command(command, in_directory: @specified_directory)
20
27
  git_command = [git_client]
21
28
  git_command += ["-C", in_directory] if in_directory
22
- git_command += command
29
+ git_command + command
30
+ end
31
+
32
+ def run_git_command(command, in_directory: @specified_directory, strip: true)
33
+ git_command = build_git_command(command, in_directory:)
23
34
 
24
35
  puts %[run_git_command (joined): #{git_command.join(" ")}] if Gemstar.debug?
25
36
 
@@ -28,6 +39,17 @@ module Gemstar
28
39
  strip ? output.strip : output
29
40
  end
30
41
 
42
+ def try_git_command(command, in_directory: @specified_directory, strip: true)
43
+ git_command = build_git_command(command, in_directory:)
44
+
45
+ puts %[try_git_command (joined): #{git_command.join(" ")}] if Gemstar.debug?
46
+
47
+ output, status = Open3.capture2e(*git_command)
48
+ return nil unless status.success?
49
+
50
+ strip ? output.strip : output
51
+ end
52
+
31
53
  def resolve_commit(revish, default_branch: "HEAD")
32
54
  # If it looks like a pure date (or you want to support "date only"),
33
55
  # map it to "latest commit before date on default_branch".
@@ -51,5 +73,50 @@ module Gemstar
51
73
  def get_full_path(path)
52
74
  run_git_command(["ls-files", "--full-name", "--", path])
53
75
  end
76
+
77
+ def relative_path(path)
78
+ return nil if tree_root_directory.nil? || tree_root_directory.empty?
79
+
80
+ Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(tree_root_directory)).to_s
81
+ rescue ArgumentError
82
+ nil
83
+ end
84
+
85
+ def origin_repo_url
86
+ remote = try_git_command(["remote", "get-url", "origin"])
87
+ return nil if remote.nil? || remote.empty?
88
+
89
+ normalize_remote_url(remote)
90
+ end
91
+
92
+ def log_for_paths(paths, limit: 20, reverse: false)
93
+ return "" if tree_root_directory.nil? || tree_root_directory.empty? || paths.empty?
94
+
95
+ format = "%H%x1f%h%x1f%aI%x1f%s"
96
+ command = ["log"]
97
+ command += ["-n", limit.to_s] if limit
98
+ command << "--reverse" if reverse
99
+ command += ["--pretty=format:#{format}", "--", *paths]
100
+
101
+ run_git_command(command, in_directory: tree_root_directory)
102
+ end
103
+
104
+ private
105
+
106
+ def normalize_remote_url(remote)
107
+ normalized = remote.strip.sub(%r{\.git\z}, "")
108
+
109
+ if normalized.start_with?("git@github.com:")
110
+ path = normalized.delete_prefix("git@github.com:")
111
+ return "https://github.com/#{path}"
112
+ end
113
+
114
+ if normalized.start_with?("ssh://git@github.com/")
115
+ path = normalized.delete_prefix("ssh://git@github.com/")
116
+ return "https://github.com/#{path}"
117
+ end
118
+
119
+ normalized.sub(%r{\Ahttp://}, "https://")
120
+ end
54
121
  end
55
122
  end
@@ -2,29 +2,107 @@ module Gemstar
2
2
  class LockFile
3
3
  def initialize(path: nil, content: nil)
4
4
  @path = path
5
- @specs = content ? parse_content(content) : parse_lockfile(path)
5
+ parsed = content ? parse_content(content) : parse_lockfile(path)
6
+ @specs = parsed[:specs]
7
+ @dependency_graph = parsed[:dependency_graph]
8
+ @direct_dependencies = parsed[:direct_dependencies]
6
9
  end
7
10
 
8
11
  attr_reader :specs
12
+ attr_reader :dependency_graph
13
+ attr_reader :direct_dependencies
14
+
15
+ def origins_for(gem_name)
16
+ return [{ type: :direct, path: [gem_name] }] if direct_dependencies.include?(gem_name)
17
+
18
+ direct_dependencies.filter_map do |root_dependency|
19
+ path = shortest_path_from(root_dependency, gem_name)
20
+ next if path.nil?
21
+
22
+ { type: :transitive, path: path }
23
+ end
24
+ end
9
25
 
10
26
  private
11
27
 
28
+ def shortest_path_from(root_dependency, target_gem)
29
+ queue = [[root_dependency, [root_dependency]]]
30
+ visited = {}
31
+
32
+ until queue.empty?
33
+ current_name, path = queue.shift
34
+ next if visited[current_name]
35
+
36
+ visited[current_name] = true
37
+
38
+ Array(dependency_graph[current_name]).each do |dependency_name|
39
+ next_path = path + [dependency_name]
40
+ return next_path if dependency_name == target_gem
41
+
42
+ queue << [dependency_name, next_path]
43
+ end
44
+ end
45
+
46
+ nil
47
+ end
48
+
12
49
  def parse_lockfile(path)
13
50
  parse_content(File.read(path))
14
51
  end
15
52
 
16
53
  def parse_content(content)
17
54
  specs = {}
18
- in_specs = false
55
+ dependency_graph = Hash.new { |hash, key| hash[key] = [] }
56
+ direct_dependencies = []
57
+ current_section = nil
58
+ current_spec = nil
59
+
19
60
  content.each_line do |line|
20
- in_specs = true if line.strip == "GEM"
21
- next unless in_specs
22
- if line =~ /^\s{4}(\S+) \((.+)\)/
23
- name, version = $1, $2
24
- specs[name] = version
61
+ stripped = line.strip
62
+
63
+ if stripped.match?(/\A[A-Z][A-Z0-9 ]*\z/)
64
+ current_section = nil
65
+ current_spec = nil
66
+ end
67
+
68
+ if stripped == "GEM"
69
+ current_section = :gem
70
+ current_spec = nil
71
+ next
72
+ end
73
+
74
+ if stripped == "DEPENDENCIES"
75
+ current_section = :dependencies
76
+ current_spec = nil
77
+ next
78
+ end
79
+
80
+ if stripped.empty?
81
+ current_spec = nil if current_section == :dependencies
82
+ next
83
+ end
84
+
85
+ case current_section
86
+ when :gem
87
+ if line =~ /^\s{4}(\S+) \((.+)\)/
88
+ name, version = Regexp.last_match(1), Regexp.last_match(2)
89
+ specs[name] = version
90
+ current_spec = name
91
+ elsif current_spec && line =~ /^\s{6}([^\s(]+)/
92
+ dependency_graph[current_spec] << Regexp.last_match(1)
93
+ end
94
+ when :dependencies
95
+ if line =~ /^\s{2}([^\s!(]+)/
96
+ direct_dependencies << Regexp.last_match(1)
97
+ end
25
98
  end
26
99
  end
27
- specs
100
+
101
+ {
102
+ specs: specs,
103
+ dependency_graph: dependency_graph.transform_values(&:uniq),
104
+ direct_dependencies: direct_dependencies.uniq
105
+ }
28
106
  end
29
107
  end
30
108
  end
@@ -0,0 +1,245 @@
1
+ require "time"
2
+
3
+ module Gemstar
4
+ class Project
5
+ attr_reader :directory
6
+ attr_reader :gemfile_path
7
+ attr_reader :lockfile_path
8
+ attr_reader :name
9
+
10
+ def self.from_cli_argument(input)
11
+ expanded_input = File.expand_path(input)
12
+ gemfile_path = if File.directory?(expanded_input)
13
+ File.join(expanded_input, "Gemfile")
14
+ else
15
+ expanded_input
16
+ end
17
+
18
+ raise ArgumentError, "No Gemfile found for #{input}" unless File.file?(gemfile_path)
19
+ raise ArgumentError, "#{gemfile_path} is not a Gemfile" unless File.basename(gemfile_path) == "Gemfile"
20
+
21
+ new(gemfile_path)
22
+ end
23
+
24
+ def initialize(gemfile_path)
25
+ @gemfile_path = File.expand_path(gemfile_path)
26
+ @directory = File.dirname(@gemfile_path)
27
+ @lockfile_path = File.join(@directory, "Gemfile.lock")
28
+ @name = File.basename(@directory)
29
+ end
30
+
31
+ def git_repo
32
+ @git_repo ||= Gemstar::GitRepo.new(directory)
33
+ end
34
+
35
+ def git_root
36
+ git_repo.tree_root_directory
37
+ end
38
+
39
+ def lockfile?
40
+ File.file?(lockfile_path)
41
+ end
42
+
43
+ def current_lockfile
44
+ return nil unless lockfile?
45
+
46
+ @current_lockfile ||= Gemstar::LockFile.new(path: lockfile_path)
47
+ end
48
+
49
+ def revision_history(limit: 20)
50
+ history_for_paths(tracked_git_paths, limit: limit)
51
+ end
52
+
53
+ def lockfile_revision_history(limit: 20)
54
+ return [] unless lockfile?
55
+
56
+ relative_path = git_repo.relative_path(lockfile_path)
57
+ return [] if relative_path.nil?
58
+
59
+ history_for_paths([relative_path], limit: limit)
60
+ end
61
+
62
+ def gemfile_revision_history(limit: 20)
63
+ relative_path = git_repo.relative_path(gemfile_path)
64
+ return [] if relative_path.nil?
65
+
66
+ history_for_paths([relative_path], limit: limit)
67
+ end
68
+
69
+ def default_from_revision_id
70
+ default_changed_lockfile_revision_id ||
71
+ gemfile_revision_history(limit: 1).first&.dig(:id) ||
72
+ "worktree"
73
+ end
74
+
75
+ def revision_options(limit: 20)
76
+ [{ id: "worktree", label: "Worktree", description: "Current Gemfile.lock in the working tree" }] +
77
+ revision_history(limit: limit).map do |revision|
78
+ {
79
+ id: revision[:id],
80
+ label: revision[:short_sha],
81
+ description: "#{revision[:subject]} (#{revision[:authored_at].strftime("%Y-%m-%d %H:%M")})"
82
+ }
83
+ end
84
+ end
85
+
86
+ def lockfile_for_revision(revision_id)
87
+ return current_lockfile if revision_id.nil? || revision_id == "worktree"
88
+ return nil unless lockfile?
89
+
90
+ relative_lockfile_path = git_repo.relative_path(lockfile_path)
91
+ return nil if relative_lockfile_path.nil?
92
+
93
+ content = git_repo.try_git_command(["show", "#{revision_id}:#{relative_lockfile_path}"])
94
+ return nil if content.nil? || content.empty?
95
+
96
+ Gemstar::LockFile.new(content: content)
97
+ end
98
+
99
+ def gem_states(from_revision_id: default_from_revision_id, to_revision_id: "worktree")
100
+ from_lockfile = lockfile_for_revision(from_revision_id)
101
+ to_lockfile = lockfile_for_revision(to_revision_id)
102
+ from_specs = from_lockfile&.specs || {}
103
+ to_specs = to_lockfile&.specs || {}
104
+
105
+ (from_specs.keys | to_specs.keys).map do |gem_name|
106
+ old_version = from_specs[gem_name]
107
+ new_version = to_specs[gem_name]
108
+ bundle_origins = to_lockfile&.origins_for(gem_name) || []
109
+
110
+ {
111
+ name: gem_name,
112
+ old_version: old_version,
113
+ new_version: new_version,
114
+ status: gem_status(old_version, new_version),
115
+ version_label: version_label(old_version, new_version),
116
+ bundle_origins: bundle_origins,
117
+ bundle_origin_labels: bundle_origin_labels(bundle_origins)
118
+ }
119
+ end.sort_by { |gem| gem[:name] }
120
+ end
121
+
122
+ def gem_added_on(gem_name, revision_id: "worktree")
123
+ return nil unless lockfile?
124
+
125
+ target_lockfile = lockfile_for_revision(revision_id)
126
+ return nil unless target_lockfile&.specs&.key?(gem_name)
127
+
128
+ relative_path = git_repo.relative_path(lockfile_path)
129
+ return nil if relative_path.nil?
130
+
131
+ first_seen_revision = history_for_paths([relative_path], limit: nil, reverse: true).find do |revision|
132
+ lockfile = lockfile_for_revision(revision[:id])
133
+ lockfile&.specs&.key?(gem_name)
134
+ end
135
+
136
+ return worktree_added_on_info if first_seen_revision.nil? && revision_id == "worktree"
137
+ return nil unless first_seen_revision
138
+
139
+ {
140
+ project_name: name,
141
+ date: first_seen_revision[:authored_at].strftime("%Y-%m-%d"),
142
+ revision: first_seen_revision[:short_sha],
143
+ revision_url: revision_url(first_seen_revision[:id]),
144
+ worktree: false
145
+ }
146
+ end
147
+
148
+ private
149
+
150
+ def default_changed_lockfile_revision_id
151
+ return nil unless lockfile?
152
+
153
+ current_specs = current_lockfile&.specs || {}
154
+
155
+ lockfile_revision_history(limit: 20).find do |revision|
156
+ revision_lockfile = lockfile_for_revision(revision[:id])
157
+ revision_lockfile && revision_lockfile.specs != current_specs
158
+ end&.dig(:id)
159
+ end
160
+
161
+ def history_for_paths(paths, limit: 20, reverse: false)
162
+ return [] if git_root.nil? || git_root.empty?
163
+ return [] if paths.empty?
164
+
165
+ output = git_repo.log_for_paths(paths, limit: limit, reverse: reverse)
166
+ return [] if output.nil? || output.empty?
167
+
168
+ output.lines.filter_map do |line|
169
+ full_sha, short_sha, authored_at, subject = line.strip.split("\u001f", 4)
170
+ next if full_sha.nil?
171
+
172
+ {
173
+ id: full_sha,
174
+ full_sha: full_sha,
175
+ short_sha: short_sha,
176
+ authored_at: Time.iso8601(authored_at),
177
+ subject: subject
178
+ }
179
+ end
180
+ rescue ArgumentError
181
+ []
182
+ end
183
+
184
+ def tracked_git_paths
185
+ [gemfile_path, lockfile_path].filter_map do |path|
186
+ next unless File.file?(path)
187
+
188
+ git_repo.relative_path(path)
189
+ end.uniq
190
+ end
191
+
192
+ def gem_status(old_version, new_version)
193
+ return :added if old_version.nil? && !new_version.nil?
194
+ return :removed if !old_version.nil? && new_version.nil?
195
+ return :unchanged if old_version == new_version
196
+
197
+ comparison = compare_versions(new_version, old_version)
198
+ return :upgrade if comparison.positive?
199
+ return :downgrade if comparison.negative?
200
+
201
+ :changed
202
+ end
203
+
204
+ def version_label(old_version, new_version)
205
+ return "new → #{new_version}" if old_version.nil? && !new_version.nil?
206
+ return "#{old_version} → removed" if !old_version.nil? && new_version.nil?
207
+ return new_version.to_s if old_version == new_version
208
+
209
+ "#{old_version} → #{new_version}"
210
+ end
211
+
212
+ def compare_versions(left, right)
213
+ Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, ""))
214
+ rescue ArgumentError
215
+ left.to_s <=> right.to_s
216
+ end
217
+
218
+ def bundle_origin_labels(origins)
219
+ Array(origins).map do |origin|
220
+ next "Gemfile" if origin[:type] == :direct
221
+
222
+ ["Gemfile", *origin[:path]].join(" → ")
223
+ end.compact.uniq
224
+ end
225
+
226
+ def worktree_added_on_info
227
+ return nil unless File.file?(lockfile_path)
228
+
229
+ {
230
+ project_name: name,
231
+ date: File.mtime(lockfile_path).strftime("%Y-%m-%d"),
232
+ revision: "Worktree",
233
+ revision_url: nil,
234
+ worktree: true
235
+ }
236
+ end
237
+
238
+ def revision_url(full_sha)
239
+ repo_url = git_repo.origin_repo_url
240
+ return nil unless repo_url&.include?("github.com")
241
+
242
+ "#{repo_url}/commit/#{full_sha}"
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,31 @@
1
+ module Gemstar
2
+ class RequestLogger
3
+ def initialize(app, io: $stderr)
4
+ @app = app
5
+ @io = io
6
+ end
7
+
8
+ def call(env)
9
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ status, headers, body = @app.call(env)
11
+ log_request(env, status, started_at)
12
+ [status, headers, body]
13
+ rescue StandardError => e
14
+ log_request(env, 500, started_at, error: e)
15
+ raise
16
+ end
17
+
18
+ private
19
+
20
+ def log_request(env, status, started_at, error: nil)
21
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
22
+ path = env["PATH_INFO"].to_s
23
+ query = env["QUERY_STRING"].to_s
24
+ full_path = query.empty? ? path : "#{path}?#{query}"
25
+ method = env["REQUEST_METHOD"].to_s
26
+ suffix = error ? " #{error.class}: #{error.message}" : ""
27
+
28
+ @io.puts "[gemstar] #{method} #{full_path} -> #{status} in #{duration_ms}ms#{suffix}"
29
+ end
30
+ end
31
+ end
@@ -10,49 +10,65 @@ module Gemstar
10
10
 
11
11
  attr_reader :gem_name
12
12
 
13
- def meta
14
- @meta ||=
15
- begin
16
- url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json"
17
- Cache.fetch("rubygems-#{gem_name}") do
18
- URI.open(url).read
19
- end.then { |json|
20
- begin
21
- JSON.parse(json) if json
22
- rescue
23
- nil
24
- end }
13
+ def meta(cache_only: false)
14
+ return @meta if !cache_only && defined?(@meta)
15
+
16
+ json = if cache_only
17
+ Cache.peek("rubygems-#{gem_name}")
18
+ else
19
+ url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json"
20
+ Cache.fetch("rubygems-#{gem_name}") do
21
+ URI.open(url).read
25
22
  end
23
+ end
24
+
25
+ parsed = begin
26
+ JSON.parse(json) if json
27
+ rescue
28
+ nil
29
+ end
30
+
31
+ @meta = parsed unless cache_only
32
+ parsed
26
33
  end
27
34
 
28
- def repo_uri
29
- return nil unless meta
35
+ def repo_uri(cache_only: false)
36
+ resolved_meta = meta(cache_only: cache_only)
37
+ return nil unless resolved_meta
38
+
39
+ return @repo_uri if !cache_only && defined?(@repo_uri)
40
+
41
+ repo = begin
42
+ uri = resolved_meta["source_code_uri"]
43
+
44
+ if uri.nil?
45
+ uri = resolved_meta["homepage_uri"]
46
+ if uri.include?("github.com")
47
+ uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}]
48
+ end
49
+ end
30
50
 
31
- @repo_uri ||= begin
32
- uri = meta["source_code_uri"]
51
+ uri ||= ""
33
52
 
34
- if uri.nil?
35
- uri = meta["homepage_uri"]
36
- if uri.include?("github.com")
37
- uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}]
38
- end
39
- end
53
+ uri = uri.sub("http://", "https://")
40
54
 
41
- uri ||= ""
55
+ uri = uri.gsub(/\.git$/, "")
42
56
 
43
- uri = uri.sub("http://", "https://")
57
+ if uri.include?("github.io")
58
+ uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do
59
+ "https://github.com/#{$1}/#{$2}"
60
+ end
61
+ end
44
62
 
45
- uri = uri.gsub(/\.git$/, "")
63
+ if uri.include?("github.com")
64
+ uri = uri[%r{\Ahttps?://github\.com/[^/]+/[^/]+}] || uri
65
+ end
46
66
 
47
- if uri.include?("github.io")
48
- # Convert e.g. https://socketry.github.io/console/ to https://github.com/socketry/console/
49
- uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do
50
- "https://github.com/#{$1}/#{$2}"
51
- end
52
- end
67
+ uri
68
+ end
53
69
 
54
- uri
55
- end
70
+ @repo_uri = repo unless cache_only
71
+ repo
56
72
  end
57
73
 
58
74
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemstar # :nodoc:
4
- VERSION = "0.0.2"
4
+ VERSION = "1.0"
5
5
 
6
6
  def self.debug?
7
7
  return @debug if defined?(@debug)