gemlens 0.1.0 → 0.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e74316581d6c5d398840ca97854991e86a18a84f6e24ff8ac17e060e2878371
4
- data.tar.gz: d1224fa3378a9f4bf11800f3c9ed11cfa9f3a0a2002ca0dc92005f5fb1fbb8a8
3
+ metadata.gz: d637f80ca1c7fb6bb9a21730fdb035933cbf63755f811ac1a15360aaaf504d4f
4
+ data.tar.gz: c9f07c40bbcdab8ee44ab250085ae6b46d2f950b0a23e9e8f4fd3fa946fa0ed8
5
5
  SHA512:
6
- metadata.gz: b80c10dcf884caf541e832d7a36a6a1a4bf5921e4b6893a8f10b6c7ff61c1f19aceb5c421049d4cc20cf5ebb92d2c37fe63d2461cd5d77a848a0494d2500149e
7
- data.tar.gz: 26fd74d4b129cc45acb4c2454ebcc19232af7fae38e519c8ce7cf316c5ed66c32869447c9c7edcf01f23e5b6cc20be813b3d0c262b23d74357d0d061e2277354
6
+ metadata.gz: d8cfedd6291af925de6f6596126b6a41d557fb71afbae2e9bb4cc4c6b5a83dffd3d7b30d74fd584aafaa5f798af5315e35ba67abf0a80209e65930ec0f16e768
7
+ data.tar.gz: '01338ca35f4e7631a8de6b3ad5df1b0b50d24be96da8ffd464f66af3206f2528813d9fe11495543a9138a9632b4ecea16dae86c9a5c316fed30ca3d79a7691ce'
data/.rubocop.yml CHANGED
@@ -1,3 +1,36 @@
1
+ Layout/LineLength:
2
+ Max: 120
3
+
4
+ Metrics/AbcSize:
5
+ Enabled: true
6
+ Max: 30
7
+ Details: >
8
+ Code that involves a lot of branches can be very hard to wrap your head
9
+ around.
10
+
11
+ Metrics/ClassLength:
12
+ Max: 110
13
+
14
+ Metrics/MethodLength:
15
+ Enabled: true
16
+ Max: 30
17
+ Details: >
18
+ Long methods can be very hard to review. Consider splitting this method up
19
+ into separate methods.
20
+
21
+ Metrics/ParameterLists:
22
+ Max: 6
23
+
24
+ Metrics/PerceivedComplexity:
25
+ Enabled: true
26
+ Max: 14
27
+ Description: >
28
+ A complexity metric geared towards measuring complexity for a
29
+ human reader.
30
+
31
+ Style/Documentation:
32
+ Enabled: false
33
+
1
34
  Style/StringLiterals:
2
35
  Enabled: true
3
36
  EnforcedStyle: double_quotes
@@ -5,6 +38,3 @@ Style/StringLiterals:
5
38
  Style/StringLiteralsInInterpolation:
6
39
  Enabled: true
7
40
  EnforcedStyle: double_quotes
8
-
9
- Layout/LineLength:
10
- Max: 120
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # GemLens
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/gemlens.svg)](https://rubygems.org/gems/gemlens)
4
+
3
5
  **Gemlens** is a CLI tool to analyze the evolution of your `Gemfile` over time. It helps you track when gems were added, removed, or updated in a project — great for audits, debugging, or historical exploration.
4
6
 
5
7
  ---
data/bin/gemlens CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  #!/usr/bin/env ruby
2
3
 
3
4
  require "bundler/setup"
data/gemlens.gemspec CHANGED
@@ -3,15 +3,18 @@
3
3
  require_relative "lib/gemlens/version"
4
4
 
5
5
  Gem::Specification.new do |s|
6
- s.name = "gemlens"
7
- s.version = Gemlens::VERSION
8
- s.authors = ["Fai Wong"]
9
- s.email = ["wongwf82@gmail.com"]
6
+ s.name = "gemlens"
7
+ s.version = Gemlens::VERSION
8
+ s.authors = ["Fai Wong"]
9
+ s.email = ["wongwf82@gmail.com"]
10
10
 
11
- s.summary = "Track and visualize changes to your Gemfile over time."
12
- s.description = "GemLens is a developer tool that analyzes the history of your Gemfile using Git and presents a timeline of gem additions, removals, and version updates. It's useful for auditing dependency changes, generating changelogs, and understanding how your project's gem dependencies evolved. Built for maintainers, teams, and curious developers."
13
- s.license = "MIT"
14
- s.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
11
+ s.summary = "Track and visualize changes to your Gemfile over time."
12
+ s.description = "GemLens is a developer tool that analyzes the history of your Gemfile using Git " \
13
+ "and presents a timeline of gem additions, removals, and version updates. It's useful " \
14
+ "for auditing dependency changes, generating changelogs, and understanding how your " \
15
+ "project's gem dependencies evolved."
16
+ s.license = "MIT"
17
+ s.required_ruby_version = ">= 2.4.0"
15
18
 
16
19
  s.homepage = "https://github.com/BestBitsLab/gemlens"
17
20
  s.metadata["source_code_uri"] = "https://github.com/BestBitsLab/gemlens"
@@ -21,8 +24,8 @@ Gem::Specification.new do |s|
21
24
  s.files = Dir.chdir(File.expand_path(__dir__)) do
22
25
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
26
  end
24
- s.bindir = "bin"
25
- s.executables = s.files.grep(%r{\Abin/}) { |f| File.basename(f) }
27
+ s.bindir = "bin"
28
+ s.executables = s.files.grep(%r{\Abin/}) { |f| File.basename(f) }
26
29
  s.require_paths = ["lib"]
27
30
 
28
31
  s.add_dependency "colorize", "~> 1.1.0"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "date"
2
4
  require "json"
3
5
  require "open3"
@@ -5,7 +7,7 @@ require "colorize"
5
7
  require "yaml"
6
8
 
7
9
  module Gemlens
8
- GEMS_YML_PATH = File.expand_path("../../../data/gems.yml", __FILE__)
10
+ GEMS_YML_PATH = File.expand_path("../../data/gems.yml", __dir__)
9
11
  GEMS_YML = File.exist?(GEMS_YML_PATH) ? YAML.load_file(GEMS_YML_PATH) : []
10
12
 
11
13
  GEM_TO_REPOS = GEMS_YML.each_with_object({}) do |entry, hash|
@@ -17,91 +19,119 @@ module Gemlens
17
19
  @repo_path = repo_path
18
20
  end
19
21
 
20
- def gemfile_commits
21
- Dir.chdir(@repo_path) do
22
- unless system("git rev-parse HEAD > /dev/null 2>&1")
23
- puts "⚠️ No commits found in the repository yet.".colorize(:yellow)
24
- return []
25
- end
22
+ def analyze
23
+ return not_enough_history if gemfile_commits.size < 2
26
24
 
27
- `git log --follow --format="%H|%an|%ad|%s" --date=iso Gemfile`.lines.map do |line|
28
- sha, author, date, message = line.strip.split("|", 4)
25
+ history = Hash.new { |h, k| h[k] = [] }
29
26
 
30
- # Extract PR number if present
31
- pr = message[/(?:#|PR\s*)(\d+)/i, 1]
27
+ gemfile_commits.reverse.each_cons(2) do |older, newer|
28
+ before = parse_gemfile_at(older[:sha])
29
+ after = parse_gemfile_at(newer[:sha])
32
30
 
33
- {
34
- sha: sha,
35
- author: author,
36
- date: DateTime.parse(date),
37
- message: message.strip,
38
- pr: pr
39
- }
40
- end
31
+ process_changes(before, after, newer, history)
41
32
  end
33
+
34
+ history
42
35
  end
43
36
 
44
- def parse_gemfile_at(commit_sha)
45
- content = Dir.chdir(@repo_path) do
46
- `git show #{commit_sha}:Gemfile`
47
- end
37
+ def not_enough_history
38
+ puts "📘 Only one commit related to Gemfile found. Not enough history to analyze changes.".colorize(:light_blue)
39
+ {}
40
+ end
48
41
 
49
- gems = {}
50
- content.each_line do |line|
51
- if line =~ /^\s*gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/
52
- name = $1
53
- version = $2
54
- gems[name] = version
55
- end
42
+ def process_changes(before, after, commit, history)
43
+ added = after.keys - before.keys
44
+ removed = before.keys - after.keys
45
+ common = before.keys & after.keys
46
+
47
+ handle_added(added, after, commit, history)
48
+ handle_removed(removed, before, commit, history)
49
+ handle_updated(common, before, after, removed, commit, history)
50
+ end
51
+
52
+ def handle_added(added, after, commit, history)
53
+ added.each do |gem_name|
54
+ history[gem_name] << build_event(
55
+ "added", gem_name,
56
+ commit.merge(version: after[gem_name])
57
+ )
56
58
  end
57
- gems
58
59
  end
59
60
 
60
- def analyze
61
- history = Hash.new { |h, k| h[k] = [] }
61
+ def handle_removed(removed, before, commit, history)
62
+ removed.each do |gem_name|
63
+ history[gem_name] << build_event(
64
+ "removed", gem_name,
65
+ commit.merge(version: before[gem_name])
66
+ )
67
+ end
68
+ end
62
69
 
63
- if gemfile_commits.size < 2
64
- puts "📘 Only one commit related to Gemfile found. Not enough history to analyze changes.".colorize(:light_blue)
65
- return history
70
+ def handle_updated(common, before, after, removed, commit, history)
71
+ updated = common.select do |gem_name|
72
+ before[gem_name] != after[gem_name] && !removed.include?(gem_name)
66
73
  end
67
74
 
68
- gemfile_commits.reverse.each_cons(2) do |older, newer|
69
- before = parse_gemfile_at(older[:sha])
70
- after = parse_gemfile_at(newer[:sha])
75
+ updated.each do |gem_name|
76
+ history[gem_name] << build_event(
77
+ "updated", gem_name,
78
+ commit.merge(
79
+ version_from: before[gem_name],
80
+ version_to: after[gem_name]
81
+ )
82
+ )
83
+ end
84
+ end
71
85
 
72
- added = after.keys - before.keys
73
- removed = before.keys - after.keys
74
- common = before.keys & after.keys
86
+ def gemfile_commits
87
+ return [] unless valid_git_repo?
75
88
 
76
- added.each do |gem_name|
77
- history[gem_name] << build_event("added", gem_name, newer.merge(version: after[gem_name]))
78
- end
89
+ Dir.chdir(@repo_path) do
90
+ parse_git_log(`git log --follow --format="%H|%an|%ad|%s" --date=iso Gemfile`)
91
+ end
92
+ end
79
93
 
80
- # Only gems that exist in both and changed version count as updated
81
- updated = common.select do |gem_name|
82
- before[gem_name] != after[gem_name]
94
+ def valid_git_repo?
95
+ Dir.chdir(@repo_path) do
96
+ system("git rev-parse HEAD > /dev/null 2>&1") || begin
97
+ puts "⚠️ No commits found in the repository yet.".colorize(:yellow)
98
+ false
83
99
  end
100
+ end
101
+ end
84
102
 
85
- removed.each do |gem_name|
86
- history[gem_name] << build_event("removed", gem_name, newer.merge(
87
- version: before[gem_name]
88
- ))
89
- end
103
+ def parse_git_log(log_output)
104
+ log_output.lines.map do |line|
105
+ sha, author, date, message = line.strip.split("|", 4)
106
+ pr = message[/(?:#|PR\s*)(\d+)/i, 1]
90
107
 
91
- updated -= removed
108
+ {
109
+ sha: sha,
110
+ author: author,
111
+ date: DateTime.parse(date),
112
+ message: message.strip,
113
+ pr: pr
114
+ }
115
+ end
116
+ end
92
117
 
93
- updated.each do |gem_name|
94
- history[gem_name] << build_event("updated", gem_name, newer.merge(
95
- version_from: before[gem_name],
96
- version_to: after[gem_name]
97
- ))
98
- end
118
+ def parse_gemfile_at(commit_sha)
119
+ content = Dir.chdir(@repo_path) do
120
+ `git show #{commit_sha}:Gemfile`
99
121
  end
100
122
 
101
- history
123
+ gems = {}
124
+ content.each_line do |line|
125
+ next unless (match = line.match(/^\s*gem\s+["'](?<name>[^"']+)["'](?:\s*,\s*["'](?<version>[^"']+)["'])?/))
126
+
127
+ name = match[:name]
128
+ version = match[:version]
129
+ gems[name] = version
130
+ end
131
+ gems
102
132
  end
103
133
 
104
- def build_event(action, gem_name, commit)
134
+ def build_event(action, _gem_name, commit)
105
135
  {
106
136
  action: action,
107
137
  by: commit[:author],
@@ -125,8 +155,7 @@ module Gemlens
125
155
  end
126
156
  end
127
157
 
128
- def self.print_timeline(history)
129
- puts "\n📜 Gemfile History Timeline\n\n"
158
+ def self.flatten_and_sort(history)
130
159
  flattened = history.flat_map do |gem, events|
131
160
  events.map do |e|
132
161
  {
@@ -144,42 +173,82 @@ module Gemlens
144
173
  repo: e[:repo]
145
174
  }
146
175
  end
147
- end.sort_by { |e| e[:date] }
148
-
149
- flattened.each do |e|
150
- label = e[:action].capitalize.ljust(8)
151
-
152
- version_str =
153
- if e[:action] == "updated" && e[:version_from] && e[:version_to]
154
- " (#{e[:version_from]} → #{e[:version_to]})"
155
- elsif e[:version]
156
- " (#{e[:version]})"
157
- else
158
- ""
159
- end
176
+ end
160
177
 
161
- gem_label = case e[:action]
162
- when "added" then (e[:gem] + version_str).ljust(20).colorize(:green)
163
- when "removed" then (e[:gem] + version_str).ljust(20).colorize(:red)
164
- when "updated" then (e[:gem] + version_str).ljust(20).colorize(:blue)
165
- else (e[:gem] + version_str).ljust(20)
166
- end
178
+ flattened.sort_by { |e| e[:date] }
179
+ end
167
180
 
168
- pr_info = if e[:pr]
169
- "PR ##{e[:pr].rjust(4)}"
170
- elsif e[:sha]
171
- e[:sha][0..6]
172
- else
173
- "—"
174
- end
181
+ def self.format_event_line(event)
182
+ [
183
+ color_tag(event[:action]),
184
+ event[:date].strftime("%Y-%m-%d"),
185
+ format_label(event),
186
+ format_gem(event),
187
+ "by #{event[:author].ljust(10)}",
188
+ "➜ #{format_pr(event)}",
189
+ "| #{event[:message]}"
190
+ ].join(" ").strip
191
+ end
192
+
193
+ def self.format_label(event)
194
+ event[:action].capitalize.ljust(8)
195
+ end
175
196
 
176
- puts "#{color_tag(e[:action])} #{e[:date].strftime('%Y-%m-%d')} " \
177
- "#{label} #{gem_label} by #{e[:author].ljust(10)} ➜ #{pr_info} | #{e[:message]}"
197
+ def self.format_gem(event)
198
+ version_str = format_version(event)
199
+ text = (event[:gem] + version_str).ljust(20)
200
+ colorize_by_action(text, event[:action])
201
+ end
202
+
203
+ def self.format_version(event)
204
+ if event[:action] == "updated" && event[:version_from] && event[:version_to]
205
+ " (#{event[:version_from]} → #{event[:version_to]})"
206
+ elsif event[:version]
207
+ " (#{event[:version]})"
208
+ else
209
+ ""
210
+ end
211
+ end
212
+
213
+ def self.colorize_by_action(text, action)
214
+ case action
215
+ when "added" then text.colorize(:green)
216
+ when "removed" then text.colorize(:red)
217
+ when "updated" then text.colorize(:blue)
218
+ else text
219
+ end
220
+ end
221
+
222
+ def self.format_pr(event)
223
+ if event[:pr]
224
+ "PR ##{event[:pr].rjust(4)}"
225
+ elsif event[:sha]
226
+ event[:sha][0..6]
227
+ else
228
+ "—"
229
+ end
230
+ end
231
+
232
+ def self.print_timeline(history)
233
+ puts "\n📜 Gemfile History Timeline\n\n"
234
+ flatten_and_sort(history).each do |e|
235
+ puts format_event_line(e)
178
236
  end
179
237
  end
180
238
 
181
239
  def self.run(repo_path = ".")
182
240
  history = GemfileHistoryAnalyzer.new(repo_path).analyze
183
- print_timeline(history) unless history.empty?
241
+ return if history.empty?
242
+
243
+ print_timeline(history)
244
+
245
+ # Collect recently added gems
246
+ added_gems = history.select { |_gem, events| events.any? { |e| e[:action] == "added" } }.keys
247
+ return if added_gems.empty?
248
+
249
+ puts "\n🔗 See all popular projects using the same gems as you:"
250
+ added_gems.uniq.each do |gem_name|
251
+ puts " 🌐 https://www.outrelax.com/gems/#{gem_name}"
252
+ end
184
253
  end
185
254
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemlens
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemlens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fai Wong
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-16 00:00:00.000000000 Z
11
+ date: 2025-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: colorize
@@ -27,8 +27,7 @@ dependencies:
27
27
  description: GemLens is a developer tool that analyzes the history of your Gemfile
28
28
  using Git and presents a timeline of gem additions, removals, and version updates.
29
29
  It's useful for auditing dependency changes, generating changelogs, and understanding
30
- how your project's gem dependencies evolved. Built for maintainers, teams, and curious
31
- developers.
30
+ how your project's gem dependencies evolved.
32
31
  email:
33
32
  - wongwf82@gmail.com
34
33
  executables:
@@ -41,7 +40,6 @@ files:
41
40
  - ".github/workflows/main.yml"
42
41
  - ".gitignore"
43
42
  - ".rubocop.yml"
44
- - 1git
45
43
  - CODE_OF_CONDUCT.md
46
44
  - Gemfile
47
45
  - Gemfile.lock
@@ -69,7 +67,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
67
  requirements:
70
68
  - - ">="
71
69
  - !ruby/object:Gem::Version
72
- version: 2.3.0
70
+ version: 2.4.0
73
71
  required_rubygems_version: !ruby/object:Gem::Requirement
74
72
  requirements:
75
73
  - - ">="
data/1git DELETED
@@ -1,6 +0,0 @@
1
- fatal: ambiguous argument 'log': unknown revision or path not in the working tree.
2
- Use '--' to separate paths from revisions, like this:
3
- 'git <command> [<revision>...] -- [<file>...]'
4
- fatal: ambiguous argument 'log': unknown revision or path not in the working tree.
5
- Use '--' to separate paths from revisions, like this:
6
- 'git <command> [<revision>...] -- [<file>...]'