gemlens 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,185 @@
1
+ require "date"
2
+ require "json"
3
+ require "open3"
4
+ require "colorize"
5
+ require "yaml"
6
+
7
+ module Gemlens
8
+ GEMS_YML_PATH = File.expand_path("../../../data/gems.yml", __FILE__)
9
+ GEMS_YML = File.exist?(GEMS_YML_PATH) ? YAML.load_file(GEMS_YML_PATH) : []
10
+
11
+ GEM_TO_REPOS = GEMS_YML.each_with_object({}) do |entry, hash|
12
+ hash[entry["full_name"]] = entry["repos"] || []
13
+ end
14
+
15
+ class GemfileHistoryAnalyzer
16
+ def initialize(repo_path = ".")
17
+ @repo_path = repo_path
18
+ end
19
+
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
26
+
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)
29
+
30
+ # Extract PR number if present
31
+ pr = message[/(?:#|PR\s*)(\d+)/i, 1]
32
+
33
+ {
34
+ sha: sha,
35
+ author: author,
36
+ date: DateTime.parse(date),
37
+ message: message.strip,
38
+ pr: pr
39
+ }
40
+ end
41
+ end
42
+ end
43
+
44
+ def parse_gemfile_at(commit_sha)
45
+ content = Dir.chdir(@repo_path) do
46
+ `git show #{commit_sha}:Gemfile`
47
+ end
48
+
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
56
+ end
57
+ gems
58
+ end
59
+
60
+ def analyze
61
+ history = Hash.new { |h, k| h[k] = [] }
62
+
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
66
+ end
67
+
68
+ gemfile_commits.reverse.each_cons(2) do |older, newer|
69
+ before = parse_gemfile_at(older[:sha])
70
+ after = parse_gemfile_at(newer[:sha])
71
+
72
+ added = after.keys - before.keys
73
+ removed = before.keys - after.keys
74
+ common = before.keys & after.keys
75
+
76
+ added.each do |gem_name|
77
+ history[gem_name] << build_event("added", gem_name, newer.merge(version: after[gem_name]))
78
+ end
79
+
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]
83
+ end
84
+
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
90
+
91
+ updated -= removed
92
+
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
99
+ end
100
+
101
+ history
102
+ end
103
+
104
+ def build_event(action, gem_name, commit)
105
+ {
106
+ action: action,
107
+ by: commit[:author],
108
+ at: commit[:date].to_s,
109
+ message: commit[:message],
110
+ pr: commit[:pr],
111
+ sha: commit[:sha],
112
+ version: commit[:version],
113
+ version_from: commit[:version_from], # only present on update
114
+ version_to: commit[:version_to] # only present on update
115
+ }
116
+ end
117
+ end
118
+
119
+ def self.color_tag(action)
120
+ case action
121
+ when "added" then "🟩".colorize(:green)
122
+ when "removed" then "🟥".colorize(:red)
123
+ when "updated" then "🟦".colorize(:blue)
124
+ else "⬜".colorize(:light_black)
125
+ end
126
+ end
127
+
128
+ def self.print_timeline(history)
129
+ puts "\n📜 Gemfile History Timeline\n\n"
130
+ flattened = history.flat_map do |gem, events|
131
+ events.map do |e|
132
+ {
133
+ date: DateTime.parse(e[:at]),
134
+ at: e[:at],
135
+ action: e[:action],
136
+ gem: gem,
137
+ author: e[:by],
138
+ message: e[:message],
139
+ pr: e[:pr],
140
+ sha: e[:sha],
141
+ version: e[:version],
142
+ version_from: e[:version_from],
143
+ version_to: e[:version_to],
144
+ repo: e[:repo]
145
+ }
146
+ 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
160
+
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
167
+
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
175
+
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]}"
178
+ end
179
+ end
180
+
181
+ def self.run(repo_path = ".")
182
+ history = GemfileHistoryAnalyzer.new(repo_path).analyze
183
+ print_timeline(history) unless history.empty?
184
+ end
185
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemlens
4
+ VERSION = "0.1.0"
5
+ end
data/lib/gemlens.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gemlens/version"
4
+ require_relative "gemlens/history"
5
+
6
+ module Gemlens
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemlens
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fai Wong
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-07-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: colorize
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.1.0
27
+ description: GemLens is a developer tool that analyzes the history of your Gemfile
28
+ using Git and presents a timeline of gem additions, removals, and version updates.
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.
32
+ email:
33
+ - wongwf82@gmail.com
34
+ executables:
35
+ - console
36
+ - gemlens
37
+ - setup
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - ".github/workflows/main.yml"
42
+ - ".gitignore"
43
+ - ".rubocop.yml"
44
+ - 1git
45
+ - CODE_OF_CONDUCT.md
46
+ - Gemfile
47
+ - Gemfile.lock
48
+ - LICENSE.txt
49
+ - README.md
50
+ - Rakefile
51
+ - bin/console
52
+ - bin/gemlens
53
+ - bin/setup
54
+ - gemlens.gemspec
55
+ - lib/gemlens.rb
56
+ - lib/gemlens/gems.yml
57
+ - lib/gemlens/history.rb
58
+ - lib/gemlens/version.rb
59
+ homepage: https://github.com/BestBitsLab/gemlens
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ source_code_uri: https://github.com/BestBitsLab/gemlens
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 2.3.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.2.3
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Track and visualize changes to your Gemfile over time.
83
+ test_files: []