diff_gem 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.
- checksums.yaml +7 -0
- data/.claude/settings.local.json +23 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +41 -0
- data/README.md +112 -0
- data/exe/diff_gem +11 -0
- data/lib/diff_gem/cli.rb +150 -0
- data/lib/diff_gem/comparer.rb +50 -0
- data/lib/diff_gem/gem_extractor.rb +140 -0
- data/lib/diff_gem/metadata_comparer.rb +240 -0
- data/lib/diff_gem/version.rb +3 -0
- data/lib/diff_gem.rb +9 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 73ae8c129ddec8ddc5fb106a8d34d9bf7bdc42cabf49a076e70918d006cf4e72
|
|
4
|
+
data.tar.gz: ceb5f41c3b67cfd6d3f7b58f5bdf9ed599e7cdc1ed122eb8536819cb395a7dc5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ff5a6fe8679e51eacb93bf8de2d9a8de23101c4bc13963b171d8fd0bdbff0c9fab8b4e131a71cae08bfd07ed5128ff595286000456bf0cd9c39f89a2cf0efa2b
|
|
7
|
+
data.tar.gz: d6162df393f490ec848d6f80a67432bb11ee5d203ae4ebf0655f6aa5df866a31642297703a3d098513e1c27f608059b05b89e09cdb229706eb7683b2ee2108ab
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(mkdir:*)",
|
|
5
|
+
"Bash(chmod:*)",
|
|
6
|
+
"Bash(bundle install:*)",
|
|
7
|
+
"Bash(bundle exec ruby:*)",
|
|
8
|
+
"Bash(curl:*)",
|
|
9
|
+
"Bash(gem build:*)",
|
|
10
|
+
"Bash(git init:*)",
|
|
11
|
+
"Bash(git add:*)",
|
|
12
|
+
"Bash(git commit:*)",
|
|
13
|
+
"Bash(git rm:*)",
|
|
14
|
+
"Bash(gem install:*)",
|
|
15
|
+
"Bash(gemcompare minitest:*)",
|
|
16
|
+
"Bash(gemcompare --help:*)",
|
|
17
|
+
"Bash(gemcompare cache info:*)",
|
|
18
|
+
"Bash(git mv:*)"
|
|
19
|
+
],
|
|
20
|
+
"deny": [],
|
|
21
|
+
"ask": []
|
|
22
|
+
}
|
|
23
|
+
}
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
diff_gem (0.1.0)
|
|
5
|
+
thor (~> 1.0)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
coderay (1.1.3)
|
|
11
|
+
diff-lcs (1.6.2)
|
|
12
|
+
method_source (1.1.0)
|
|
13
|
+
pry (0.15.2)
|
|
14
|
+
coderay (~> 1.1)
|
|
15
|
+
method_source (~> 1.0)
|
|
16
|
+
rspec (3.13.2)
|
|
17
|
+
rspec-core (~> 3.13.0)
|
|
18
|
+
rspec-expectations (~> 3.13.0)
|
|
19
|
+
rspec-mocks (~> 3.13.0)
|
|
20
|
+
rspec-core (3.13.6)
|
|
21
|
+
rspec-support (~> 3.13.0)
|
|
22
|
+
rspec-expectations (3.13.5)
|
|
23
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
24
|
+
rspec-support (~> 3.13.0)
|
|
25
|
+
rspec-mocks (3.13.7)
|
|
26
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
27
|
+
rspec-support (~> 3.13.0)
|
|
28
|
+
rspec-support (3.13.6)
|
|
29
|
+
thor (1.4.0)
|
|
30
|
+
|
|
31
|
+
PLATFORMS
|
|
32
|
+
arm64-darwin-22
|
|
33
|
+
ruby
|
|
34
|
+
|
|
35
|
+
DEPENDENCIES
|
|
36
|
+
diff_gem!
|
|
37
|
+
pry (~> 0.14)
|
|
38
|
+
rspec (~> 3.12)
|
|
39
|
+
|
|
40
|
+
BUNDLED WITH
|
|
41
|
+
2.7.1
|
data/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# DiffGem
|
|
2
|
+
|
|
3
|
+
Compare the source code and metadata between two versions of a Ruby gem.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install diff_gem
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install locally:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem build diff_gem.gemspec
|
|
15
|
+
gem install diff_gem-0.1.0.gem
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Compare Source Code
|
|
21
|
+
|
|
22
|
+
Compare two versions of a gem:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
diff_gem GEM_NAME OLD_VERSION NEW_VERSION
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
diff_gem rails 7.0.0 7.0.1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or use the explicit command:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
diff_gem compare rails 7.0.0 7.0.1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Compare Metadata
|
|
41
|
+
|
|
42
|
+
Compare gem metadata (dependencies, version info, file counts, etc.) without comparing source:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
diff_gem metadata rails 7.0.0 7.0.1
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Compare Both
|
|
49
|
+
|
|
50
|
+
Compare metadata and source code together:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
diff_gem compare --metadata rails 7.0.0 7.0.1
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Cache Management
|
|
57
|
+
|
|
58
|
+
View cache information:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
diff_gem cache info
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Clear the cache:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
diff_gem cache clear
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Custom Cache Directory
|
|
71
|
+
|
|
72
|
+
You can specify a custom cache directory:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
diff_gem --cache-dir /tmp/gem_cache rails 7.0.0 7.0.1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or set the environment variable:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export DIFF_GEM_CACHE_DIR=/tmp/gem_cache
|
|
82
|
+
diff_gem rails 7.0.0 7.0.1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## How It Works
|
|
86
|
+
|
|
87
|
+
**Source Comparison:**
|
|
88
|
+
1. Downloads both gem versions from rubygems.org
|
|
89
|
+
2. Extracts the gem source code to a cache directory
|
|
90
|
+
3. Runs `diff -r -u -N` to compare the source trees
|
|
91
|
+
4. Displays the unified diff output
|
|
92
|
+
|
|
93
|
+
**Metadata Comparison:**
|
|
94
|
+
1. Downloads gem metadata from rubygems.org
|
|
95
|
+
2. Extracts and parses the gemspec information
|
|
96
|
+
3. Compares dependencies, file counts, version requirements, and other metadata
|
|
97
|
+
4. Displays a structured comparison showing added, removed, and changed fields
|
|
98
|
+
|
|
99
|
+
Downloaded gems are cached in `~/.diff_gem_cache` by default, so subsequent comparisons are faster.
|
|
100
|
+
|
|
101
|
+
## Similar Tools
|
|
102
|
+
|
|
103
|
+
If `diff_gem` doesn't meet your needs, you might want to check out these alternatives:
|
|
104
|
+
|
|
105
|
+
- **[gemdiff](https://github.com/teeparham/gemdiff)** - Compare gem versions with GitHub integration, showing commits between releases and opening diffs in the browser
|
|
106
|
+
- **[gem-compare](https://github.com/sj26/gem-compare)** - Another tool for comparing gem versions with a focus on simplicity
|
|
107
|
+
|
|
108
|
+
`diff_gem` focuses on providing both detailed source code diffs and comprehensive metadata comparisons in a command-line interface.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
data/exe/diff_gem
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/diff_gem'
|
|
4
|
+
|
|
5
|
+
# If first argument is not a known command, assume it's a gem name and prepend 'compare'
|
|
6
|
+
KNOWN_COMMANDS = %w[compare metadata version cache help]
|
|
7
|
+
if ARGV.length >= 3 && !KNOWN_COMMANDS.include?(ARGV[0])
|
|
8
|
+
ARGV.unshift('compare')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
DiffGem::CLI.start(ARGV)
|
data/lib/diff_gem/cli.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module DiffGem
|
|
5
|
+
class CacheCommands < Thor
|
|
6
|
+
desc "clear", "Clear the cache directory"
|
|
7
|
+
def clear
|
|
8
|
+
cache_dir = ENV['DIFF_GEM_CACHE_DIR'] || File.expand_path('~/.diff_gem_cache')
|
|
9
|
+
|
|
10
|
+
if File.exist?(cache_dir)
|
|
11
|
+
FileUtils.rm_rf(cache_dir)
|
|
12
|
+
puts "Cache cleared: #{cache_dir}"
|
|
13
|
+
else
|
|
14
|
+
puts "Cache directory does not exist: #{cache_dir}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "info", "Show cache directory information"
|
|
19
|
+
def info
|
|
20
|
+
cache_dir = ENV['DIFF_GEM_CACHE_DIR'] || File.expand_path('~/.diff_gem_cache')
|
|
21
|
+
|
|
22
|
+
puts "Cache directory: #{cache_dir}"
|
|
23
|
+
|
|
24
|
+
if File.exist?(cache_dir)
|
|
25
|
+
gem_dirs = Dir.glob(File.join(cache_dir, '*', '*')).select { |f| File.directory?(f) }
|
|
26
|
+
|
|
27
|
+
size = `du -sh "#{cache_dir}" 2>/dev/null`.split.first rescue "unknown"
|
|
28
|
+
|
|
29
|
+
puts "Status: exists"
|
|
30
|
+
puts "Size: #{size}"
|
|
31
|
+
puts "Cached gem versions: #{gem_dirs.length}"
|
|
32
|
+
|
|
33
|
+
if gem_dirs.any?
|
|
34
|
+
puts "\nCached gems:"
|
|
35
|
+
gem_dirs.each do |dir|
|
|
36
|
+
parts = dir.split('/')
|
|
37
|
+
version = parts[-1]
|
|
38
|
+
name = parts[-2]
|
|
39
|
+
puts " #{name} #{version}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
puts "Status: does not exist"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class CLI < Thor
|
|
49
|
+
def self.exit_on_failure?
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
desc "compare GEM_NAME OLD_VERSION NEW_VERSION", "Compare two versions of a Ruby gem"
|
|
54
|
+
long_desc <<-LONGDESC
|
|
55
|
+
Compare the source trees of two different versions of a Ruby gem.
|
|
56
|
+
|
|
57
|
+
Downloads both gem versions, extracts their source code, and displays
|
|
58
|
+
the differences in unified diff format.
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
$ diff_gem rails 7.0.0 7.1.0
|
|
62
|
+
|
|
63
|
+
$ diff_gem compare rails 7.0.0 7.1.0
|
|
64
|
+
|
|
65
|
+
With metadata comparison:
|
|
66
|
+
$ diff_gem compare --metadata rails 7.0.0 7.1.0
|
|
67
|
+
LONGDESC
|
|
68
|
+
option :cache_dir,
|
|
69
|
+
type: :string,
|
|
70
|
+
desc: "Custom cache directory for downloaded gems"
|
|
71
|
+
option :metadata,
|
|
72
|
+
type: :boolean,
|
|
73
|
+
default: false,
|
|
74
|
+
desc: "Also compare gem metadata before showing source diff"
|
|
75
|
+
|
|
76
|
+
def compare(gem_name, old_version, new_version)
|
|
77
|
+
begin
|
|
78
|
+
# Show metadata comparison if requested
|
|
79
|
+
if options[:metadata]
|
|
80
|
+
metadata_comparer = MetadataComparer.new(
|
|
81
|
+
gem_name: gem_name,
|
|
82
|
+
old_version: old_version,
|
|
83
|
+
new_version: new_version
|
|
84
|
+
)
|
|
85
|
+
metadata_comparer.compare
|
|
86
|
+
puts "\n" + "=" * 80
|
|
87
|
+
puts "Source Code Comparison"
|
|
88
|
+
puts "=" * 80 + "\n\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Show source comparison
|
|
92
|
+
comparer = Comparer.new(
|
|
93
|
+
gem_name: gem_name,
|
|
94
|
+
old_version: old_version,
|
|
95
|
+
new_version: new_version,
|
|
96
|
+
cache_dir: options[:cache_dir]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
comparer.compare
|
|
100
|
+
|
|
101
|
+
rescue DiffGem::Error => e
|
|
102
|
+
puts "Error: #{e.message}"
|
|
103
|
+
exit 1
|
|
104
|
+
rescue => e
|
|
105
|
+
puts "Unexpected error: #{e.message}"
|
|
106
|
+
puts e.backtrace
|
|
107
|
+
exit 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "metadata GEM_NAME OLD_VERSION NEW_VERSION", "Compare metadata of two gem versions"
|
|
112
|
+
long_desc <<-LONGDESC
|
|
113
|
+
Compare the metadata (dependencies, version info, etc.) of two different
|
|
114
|
+
versions of a Ruby gem without comparing source code.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
$ diff_gem metadata rails 7.0.0 7.1.0
|
|
118
|
+
LONGDESC
|
|
119
|
+
|
|
120
|
+
def metadata(gem_name, old_version, new_version)
|
|
121
|
+
begin
|
|
122
|
+
metadata_comparer = MetadataComparer.new(
|
|
123
|
+
gem_name: gem_name,
|
|
124
|
+
old_version: old_version,
|
|
125
|
+
new_version: new_version
|
|
126
|
+
)
|
|
127
|
+
metadata_comparer.compare
|
|
128
|
+
|
|
129
|
+
rescue DiffGem::Error => e
|
|
130
|
+
puts "Error: #{e.message}"
|
|
131
|
+
exit 1
|
|
132
|
+
rescue => e
|
|
133
|
+
puts "Unexpected error: #{e.message}"
|
|
134
|
+
puts e.backtrace
|
|
135
|
+
exit 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc "version", "Show version"
|
|
140
|
+
def version
|
|
141
|
+
puts DiffGem::VERSION
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
desc "cache", "Manage cache directory"
|
|
145
|
+
subcommand "cache", CacheCommands
|
|
146
|
+
|
|
147
|
+
# Set compare as the default command
|
|
148
|
+
default_task :compare
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module DiffGem
|
|
4
|
+
class Comparer
|
|
5
|
+
attr_reader :gem_name, :old_version, :new_version, :cache_dir
|
|
6
|
+
|
|
7
|
+
def initialize(gem_name:, old_version:, new_version:, cache_dir: nil)
|
|
8
|
+
@gem_name = gem_name
|
|
9
|
+
@old_version = old_version
|
|
10
|
+
@new_version = new_version
|
|
11
|
+
@cache_dir = cache_dir || default_cache_dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def compare
|
|
15
|
+
puts "Comparing #{gem_name}: #{old_version} -> #{new_version}\n\n"
|
|
16
|
+
|
|
17
|
+
extractor = GemExtractor.new(cache_dir)
|
|
18
|
+
|
|
19
|
+
# Extract both versions
|
|
20
|
+
old_path = extractor.extract_gem(gem_name, old_version)
|
|
21
|
+
new_path = extractor.extract_gem(gem_name, new_version)
|
|
22
|
+
|
|
23
|
+
puts "\nGenerating diff...\n\n"
|
|
24
|
+
|
|
25
|
+
# Generate and display diff
|
|
26
|
+
generate_diff(old_path, new_path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def generate_diff(old_path, new_path)
|
|
32
|
+
# Use system diff command to generate comprehensive diff
|
|
33
|
+
# The diff command will:
|
|
34
|
+
# - Compare directories recursively (-r)
|
|
35
|
+
# - Use unified format (-u) for better readability
|
|
36
|
+
# - Show files that exist in only one directory (-N)
|
|
37
|
+
system("diff -r -u -N '#{old_path}' '#{new_path}'")
|
|
38
|
+
|
|
39
|
+
# diff returns non-zero exit status when there are differences,
|
|
40
|
+
# which is expected behavior
|
|
41
|
+
if $?.exitstatus > 1
|
|
42
|
+
raise Error, "Failed to generate diff (exit status: #{$?.exitstatus})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_cache_dir
|
|
47
|
+
ENV['DIFF_GEM_CACHE_DIR'] || File.expand_path('~/.diff_gem_cache')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'rubygems/package'
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'open-uri'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
|
|
8
|
+
module DiffGem
|
|
9
|
+
class GemExtractor
|
|
10
|
+
attr_reader :cache_dir
|
|
11
|
+
|
|
12
|
+
def initialize(cache_dir)
|
|
13
|
+
@cache_dir = cache_dir
|
|
14
|
+
ensure_cache_dir_exists
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extract_gem(gem_name, version)
|
|
18
|
+
gem_cache_dir = File.join(cache_dir, gem_name, version)
|
|
19
|
+
|
|
20
|
+
# Return existing path if already downloaded and extracted
|
|
21
|
+
if File.exist?(gem_cache_dir) && !Dir.empty?(gem_cache_dir)
|
|
22
|
+
puts " Using cached: #{gem_name} #{version}"
|
|
23
|
+
return gem_cache_dir
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts " Downloading: #{gem_name} #{version}"
|
|
27
|
+
|
|
28
|
+
# Ensure directory exists
|
|
29
|
+
FileUtils.mkdir_p(gem_cache_dir)
|
|
30
|
+
|
|
31
|
+
# Download gem file
|
|
32
|
+
gem_file_path = download_gem(gem_name, version)
|
|
33
|
+
|
|
34
|
+
# Extract gem contents
|
|
35
|
+
extract_gem_archive(gem_file_path, gem_cache_dir)
|
|
36
|
+
|
|
37
|
+
# Clean up downloaded gem file
|
|
38
|
+
File.delete(gem_file_path) if File.exist?(gem_file_path)
|
|
39
|
+
|
|
40
|
+
gem_cache_dir
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def download_gem(gem_name, version)
|
|
46
|
+
gem_filename = "#{gem_name}-#{version}.gem"
|
|
47
|
+
gem_file_path = File.join(cache_dir, gem_filename)
|
|
48
|
+
|
|
49
|
+
# Try to download from rubygems.org
|
|
50
|
+
gem_url = "https://rubygems.org/downloads/#{gem_filename}"
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
uri = URI(gem_url)
|
|
54
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
55
|
+
request = Net::HTTP::Get.new(uri)
|
|
56
|
+
response = http.request(request)
|
|
57
|
+
|
|
58
|
+
if response.code == '200'
|
|
59
|
+
File.open(gem_file_path, 'wb') do |file|
|
|
60
|
+
file.write(response.body)
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
raise Error, "Failed to download #{gem_name} #{version}: HTTP #{response.code}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
gem_file_path
|
|
68
|
+
rescue => e
|
|
69
|
+
raise Error, "Failed to download #{gem_name} #{version}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_gem_archive(gem_file_path, extract_dir)
|
|
74
|
+
begin
|
|
75
|
+
# Modern gems can be either gzipped tar or plain tar
|
|
76
|
+
File.open(gem_file_path, 'rb') do |gem_file|
|
|
77
|
+
# Try to read as gzipped first
|
|
78
|
+
tar_reader = begin
|
|
79
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.new(gem_file))
|
|
80
|
+
rescue Zlib::GzipFile::Error
|
|
81
|
+
# If not gzipped, rewind and read as plain tar
|
|
82
|
+
gem_file.rewind
|
|
83
|
+
Gem::Package::TarReader.new(gem_file)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
tar_reader.each do |entry|
|
|
87
|
+
next unless entry.file?
|
|
88
|
+
|
|
89
|
+
# Extract data.tar.gz which contains the actual gem files
|
|
90
|
+
if entry.full_name == 'data.tar.gz'
|
|
91
|
+
extract_data_tar(entry, extract_dir, compressed: true)
|
|
92
|
+
break
|
|
93
|
+
# Some gems might have uncompressed data.tar
|
|
94
|
+
elsif entry.full_name == 'data.tar'
|
|
95
|
+
extract_data_tar(entry, extract_dir, compressed: false)
|
|
96
|
+
break
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
tar_reader.close
|
|
101
|
+
end
|
|
102
|
+
rescue => e
|
|
103
|
+
raise Error, "Failed to extract #{gem_file_path}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_data_tar(data_tar_entry, extract_dir, compressed:)
|
|
108
|
+
data = data_tar_entry.read
|
|
109
|
+
|
|
110
|
+
tar_reader = if compressed
|
|
111
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(data)))
|
|
112
|
+
else
|
|
113
|
+
Gem::Package::TarReader.new(StringIO.new(data))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
tar_reader.each do |entry|
|
|
117
|
+
next if entry.directory?
|
|
118
|
+
|
|
119
|
+
file_path = File.join(extract_dir, entry.full_name)
|
|
120
|
+
|
|
121
|
+
# Ensure directory exists
|
|
122
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
123
|
+
|
|
124
|
+
# Write file contents
|
|
125
|
+
File.open(file_path, 'wb') do |file|
|
|
126
|
+
file.write(entry.read)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Preserve file permissions
|
|
130
|
+
File.chmod(entry.header.mode, file_path) if entry.header.mode
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
tar_reader.close
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ensure_cache_dir_exists
|
|
137
|
+
FileUtils.mkdir_p(cache_dir)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'rubygems/package'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
|
|
7
|
+
module DiffGem
|
|
8
|
+
class MetadataComparer
|
|
9
|
+
attr_reader :gem_name, :old_version, :new_version
|
|
10
|
+
|
|
11
|
+
def initialize(gem_name:, old_version:, new_version:)
|
|
12
|
+
@gem_name = gem_name
|
|
13
|
+
@old_version = old_version
|
|
14
|
+
@new_version = new_version
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def compare
|
|
18
|
+
puts "Fetching metadata for #{gem_name}..."
|
|
19
|
+
|
|
20
|
+
old_spec = fetch_gem_spec(gem_name, old_version)
|
|
21
|
+
new_spec = fetch_gem_spec(gem_name, new_version)
|
|
22
|
+
|
|
23
|
+
puts "\n" + "=" * 80
|
|
24
|
+
puts "Metadata Comparison: #{gem_name} #{old_version} → #{new_version}"
|
|
25
|
+
puts "=" * 80 + "\n"
|
|
26
|
+
|
|
27
|
+
compare_basic_info(old_spec, new_spec)
|
|
28
|
+
compare_dependencies(old_spec, new_spec)
|
|
29
|
+
compare_files(old_spec, new_spec)
|
|
30
|
+
compare_metadata(old_spec, new_spec)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def fetch_gem_spec(gem_name, version)
|
|
36
|
+
gem_filename = "#{gem_name}-#{version}.gem"
|
|
37
|
+
gem_url = "https://rubygems.org/downloads/#{gem_filename}"
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
uri = URI(gem_url)
|
|
41
|
+
|
|
42
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
43
|
+
request = Net::HTTP::Get.new(uri)
|
|
44
|
+
request['User-Agent'] = 'DiffGem Ruby Gem Comparator'
|
|
45
|
+
gem_data = http.request(request)
|
|
46
|
+
|
|
47
|
+
if gem_data.code != '200'
|
|
48
|
+
raise Error, "Failed to download #{gem_name} #{version}: HTTP #{gem_data.code}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse the gem file to extract metadata
|
|
52
|
+
io = StringIO.new(gem_data.body)
|
|
53
|
+
|
|
54
|
+
tar_reader = begin
|
|
55
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.new(io))
|
|
56
|
+
rescue Zlib::GzipFile::Error
|
|
57
|
+
io.rewind
|
|
58
|
+
Gem::Package::TarReader.new(io)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
metadata = nil
|
|
62
|
+
tar_reader.each do |entry|
|
|
63
|
+
if entry.full_name == 'metadata.gz'
|
|
64
|
+
metadata = Zlib::GzipReader.new(StringIO.new(entry.read)).read
|
|
65
|
+
break
|
|
66
|
+
elsif entry.full_name == 'metadata'
|
|
67
|
+
metadata = entry.read
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
tar_reader.close
|
|
72
|
+
|
|
73
|
+
raise Error, "No metadata found in gem" unless metadata
|
|
74
|
+
|
|
75
|
+
# Use safe_load with permitted classes for security
|
|
76
|
+
permitted_classes = [
|
|
77
|
+
Gem::Specification,
|
|
78
|
+
Gem::Version,
|
|
79
|
+
Gem::Requirement,
|
|
80
|
+
Gem::Dependency,
|
|
81
|
+
Time,
|
|
82
|
+
Symbol,
|
|
83
|
+
Date,
|
|
84
|
+
DateTime
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
YAML.safe_load(metadata, permitted_classes: permitted_classes, aliases: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
rescue => e
|
|
91
|
+
raise Error, "Failed to fetch metadata for #{gem_name} #{version}: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compare_basic_info(old_spec, new_spec)
|
|
96
|
+
puts "Basic Information:"
|
|
97
|
+
puts "-" * 80
|
|
98
|
+
|
|
99
|
+
compare_field("Name", old_spec.name, new_spec.name)
|
|
100
|
+
compare_field("Version", old_spec.version.to_s, new_spec.version.to_s)
|
|
101
|
+
compare_field("Authors", old_spec.authors.join(', '), new_spec.authors.join(', '))
|
|
102
|
+
compare_field("Email", old_spec.email, new_spec.email)
|
|
103
|
+
compare_field("Homepage", old_spec.homepage, new_spec.homepage)
|
|
104
|
+
compare_field("License", old_spec.license || old_spec.licenses.join(', '),
|
|
105
|
+
new_spec.license || new_spec.licenses.join(', '))
|
|
106
|
+
compare_field("Summary", old_spec.summary, new_spec.summary)
|
|
107
|
+
compare_field("Description", old_spec.description, new_spec.description)
|
|
108
|
+
compare_field("Ruby Version Required", old_spec.required_ruby_version.to_s,
|
|
109
|
+
new_spec.required_ruby_version.to_s)
|
|
110
|
+
compare_field("RubyGems Version Required", old_spec.required_rubygems_version.to_s,
|
|
111
|
+
new_spec.required_rubygems_version.to_s)
|
|
112
|
+
|
|
113
|
+
puts
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def compare_dependencies(old_spec, new_spec)
|
|
117
|
+
puts "Runtime Dependencies:"
|
|
118
|
+
puts "-" * 80
|
|
119
|
+
|
|
120
|
+
old_deps = old_spec.dependencies.select { |d| d.type == :runtime }
|
|
121
|
+
new_deps = new_spec.dependencies.select { |d| d.type == :runtime }
|
|
122
|
+
|
|
123
|
+
compare_dependency_list(old_deps, new_deps)
|
|
124
|
+
|
|
125
|
+
puts "\nDevelopment Dependencies:"
|
|
126
|
+
puts "-" * 80
|
|
127
|
+
|
|
128
|
+
old_dev_deps = old_spec.dependencies.select { |d| d.type == :development }
|
|
129
|
+
new_dev_deps = new_spec.dependencies.select { |d| d.type == :development }
|
|
130
|
+
|
|
131
|
+
compare_dependency_list(old_dev_deps, new_dev_deps)
|
|
132
|
+
|
|
133
|
+
puts
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compare_dependency_list(old_deps, new_deps)
|
|
137
|
+
old_dep_hash = old_deps.each_with_object({}) { |d, h| h[d.name] = d }
|
|
138
|
+
new_dep_hash = new_deps.each_with_object({}) { |d, h| h[d.name] = d }
|
|
139
|
+
|
|
140
|
+
all_dep_names = (old_dep_hash.keys + new_dep_hash.keys).uniq.sort
|
|
141
|
+
|
|
142
|
+
if all_dep_names.empty?
|
|
143
|
+
puts " (none)"
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
all_dep_names.each do |name|
|
|
148
|
+
old_dep = old_dep_hash[name]
|
|
149
|
+
new_dep = new_dep_hash[name]
|
|
150
|
+
|
|
151
|
+
if old_dep && !new_dep
|
|
152
|
+
puts " - #{name} #{old_dep.requirement} (REMOVED)"
|
|
153
|
+
elsif !old_dep && new_dep
|
|
154
|
+
puts " + #{name} #{new_dep.requirement} (ADDED)"
|
|
155
|
+
elsif old_dep.requirement.to_s != new_dep.requirement.to_s
|
|
156
|
+
puts " ~ #{name}: #{old_dep.requirement} → #{new_dep.requirement}"
|
|
157
|
+
else
|
|
158
|
+
puts " #{name} #{new_dep.requirement}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def compare_files(old_spec, new_spec)
|
|
164
|
+
puts "Files:"
|
|
165
|
+
puts "-" * 80
|
|
166
|
+
|
|
167
|
+
old_files = old_spec.files.sort
|
|
168
|
+
new_files = new_spec.files.sort
|
|
169
|
+
|
|
170
|
+
removed = old_files - new_files
|
|
171
|
+
added = new_files - old_files
|
|
172
|
+
unchanged = old_files & new_files
|
|
173
|
+
|
|
174
|
+
puts " Total files: #{old_files.length} → #{new_files.length}"
|
|
175
|
+
|
|
176
|
+
if added.any?
|
|
177
|
+
puts "\n Added (#{added.length}):"
|
|
178
|
+
added.first(10).each { |f| puts " + #{f}" }
|
|
179
|
+
puts " ... and #{added.length - 10} more" if added.length > 10
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if removed.any?
|
|
183
|
+
puts "\n Removed (#{removed.length}):"
|
|
184
|
+
removed.first(10).each { |f| puts " - #{f}" }
|
|
185
|
+
puts " ... and #{removed.length - 10} more" if removed.length > 10
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
puts "\n Unchanged: #{unchanged.length} files"
|
|
189
|
+
puts
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def compare_metadata(old_spec, new_spec)
|
|
193
|
+
puts "Additional Metadata:"
|
|
194
|
+
puts "-" * 80
|
|
195
|
+
|
|
196
|
+
old_metadata = old_spec.metadata || {}
|
|
197
|
+
new_metadata = new_spec.metadata || {}
|
|
198
|
+
|
|
199
|
+
all_keys = (old_metadata.keys + new_metadata.keys).uniq.sort
|
|
200
|
+
|
|
201
|
+
if all_keys.empty?
|
|
202
|
+
puts " (none)"
|
|
203
|
+
puts
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
all_keys.each do |key|
|
|
208
|
+
old_value = old_metadata[key]
|
|
209
|
+
new_value = new_metadata[key]
|
|
210
|
+
|
|
211
|
+
if old_value && !new_value
|
|
212
|
+
puts " - #{key}: #{old_value}"
|
|
213
|
+
elsif !old_value && new_value
|
|
214
|
+
puts " + #{key}: #{new_value}"
|
|
215
|
+
elsif old_value != new_value
|
|
216
|
+
puts " ~ #{key}:"
|
|
217
|
+
puts " #{old_value}"
|
|
218
|
+
puts " → #{new_value}"
|
|
219
|
+
else
|
|
220
|
+
puts " #{key}: #{new_value}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
puts
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def compare_field(label, old_value, new_value)
|
|
228
|
+
old_value = old_value.to_s.strip
|
|
229
|
+
new_value = new_value.to_s.strip
|
|
230
|
+
|
|
231
|
+
if old_value != new_value && !old_value.empty?
|
|
232
|
+
puts " ~ #{label}:"
|
|
233
|
+
puts " #{old_value}"
|
|
234
|
+
puts " → #{new_value}"
|
|
235
|
+
else
|
|
236
|
+
puts " #{label}: #{new_value}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/diff_gem.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: diff_gem
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Developer
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-11-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: thor
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '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.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.12'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.12'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: pry
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.14'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.14'
|
|
55
|
+
description: A tool to download and compare the source trees of two different versions
|
|
56
|
+
of a Ruby gem, displaying differences in diff format
|
|
57
|
+
email:
|
|
58
|
+
- developer@example.com
|
|
59
|
+
executables:
|
|
60
|
+
- diff_gem
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- ".claude/settings.local.json"
|
|
65
|
+
- Gemfile
|
|
66
|
+
- Gemfile.lock
|
|
67
|
+
- README.md
|
|
68
|
+
- exe/diff_gem
|
|
69
|
+
- lib/diff_gem.rb
|
|
70
|
+
- lib/diff_gem/cli.rb
|
|
71
|
+
- lib/diff_gem/comparer.rb
|
|
72
|
+
- lib/diff_gem/gem_extractor.rb
|
|
73
|
+
- lib/diff_gem/metadata_comparer.rb
|
|
74
|
+
- lib/diff_gem/version.rb
|
|
75
|
+
homepage: https://github.com/example/diff_gem
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata:
|
|
79
|
+
homepage_uri: https://github.com/example/diff_gem
|
|
80
|
+
source_code_uri: https://github.com/example/diff_gem
|
|
81
|
+
changelog_uri: https://github.com/example/diff_gem/CHANGELOG.md
|
|
82
|
+
post_install_message:
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 2.6.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 3.4.10
|
|
98
|
+
signing_key:
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: Compare source code between two versions of a Ruby gem
|
|
101
|
+
test_files: []
|