gem-repair 0.2.1 → 0.3.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 +4 -4
- data/README.md +40 -3
- data/lib/gem_repair/fanout.rb +93 -0
- data/lib/gem_repair/sweep.rb +56 -0
- data/lib/rubygems/commands/repair_command.rb +58 -0
- data/lib/rubygems_plugin.rb +16 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c060c1e8acac437d6edfaab550b3a43a7bb19bde988e3307900610ce71fe0db2
|
|
4
|
+
data.tar.gz: 2c753428a55544e7486f0acda99ca68868880a9367aeabe0994cd27c893925c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 635ff70eec357b7300bc368df9b9dedd2ec88db326b39c5748e2ac00726c97ce6b5c043d00526e34d3e5b8891380468c946dc30214d3ba484a0c118f6f3e9e08
|
|
7
|
+
data.tar.gz: 748b0a6d4396cfb10018ff025f1be733dd3444babc2c5eb88337878b45c6a7f66fd70a836e83b6310121f4b2704bee2e5cd1478dcffd6eacbd06e668746ff539
|
data/README.md
CHANGED
|
@@ -33,12 +33,49 @@ gem repair --jobs 2 # Use 2 threads
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
This command will:
|
|
36
|
-
1.
|
|
37
|
-
2.
|
|
36
|
+
1. Remove the copies of built extensions left in the `lib` directory of every gem with extensions (see below).
|
|
37
|
+
2. Find all installed gems that are missing their compiled C extensions.
|
|
38
|
+
3. Attempt to reinstall these gems using multiple threads.
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
|
|
42
|
+
| Option | Effect |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `-j N`, `--jobs N` | Number of parallel threads (default: 4) |
|
|
45
|
+
| `--prune` | Uninstall gems whose extensions are still missing for the running ruby after repair, even when the reinstall itself failed |
|
|
46
|
+
| `--aggressive-sweep` | Also remove the `test`, `spec`, `features` and `tmp` directories of every gem |
|
|
47
|
+
| `-n`, `--dry-run` | Show what would be removed, repaired or uninstalled without changing anything |
|
|
48
|
+
|
|
49
|
+
## Removing extension copies from lib
|
|
50
|
+
|
|
51
|
+
RubyGems copies each built extension into the gem's `lib` directory as well as the per-ruby `extensions/<platform>/<abi>/` directory. RubyGems 3.6 added the `install_extension_in_lib` gemrc setting to skip that copy, but it stays on by default. `lib` comes first on the load path, so every other ruby sharing the `GEM_HOME` loads the copy built for the installing ruby and fails with a `LoadError`. The plugin removes those copies right after each install and on every `gem repair`. Copies under versioned directories such as `lib/3.3/`, which precompiled gems use to ship one extension per ABI, are left alone.
|
|
52
|
+
|
|
53
|
+
### Migrating from gem-sweep
|
|
54
|
+
|
|
55
|
+
This replaces the `gem sweep` command and the install hook of the gem-sweep plugin. Uninstall it so the two hooks do not run side by side:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
gem uninstall gem-sweep
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`gem sweep --missing-extensions` becomes `gem repair --prune`, `gem sweep --aggressive` becomes `gem repair --aggressive-sweep`, and `gem sweep -n` becomes `gem repair -n`.
|
|
62
|
+
|
|
63
|
+
## Building for other rubies on install
|
|
64
|
+
|
|
65
|
+
Several rubies can share one `GEM_HOME`, but `gem install` only compiles extensions for the ruby that runs it. The plugin hooks `Gem.post_install` and builds the extensions of the gem just installed for every other ruby it can find, so switching rubies does not leave the gem ignored with "extensions are not built".
|
|
66
|
+
|
|
67
|
+
Rubies are looked up under the install roots of mise and rbenv:
|
|
68
|
+
|
|
69
|
+
| Variable | Default | Rubies |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `MISE_DATA_DIR` | `$XDG_DATA_HOME/mise` | `installs/ruby/*` |
|
|
72
|
+
| `RBENV_ROOT` | `~/.rbenv` | `versions/*` |
|
|
73
|
+
|
|
74
|
+
`GEM_REPAIR_RUBIES` narrows the targets to a comma-separated list of version names, for example `GEM_REPAIR_RUBIES=3.3-dev,3.4-dev`. Set it to an empty string to turn the fan-out off. JRuby, TruffleRuby, mruby and Rubinius installs are always skipped, as is a ruby whose version the gem does not support. The fan-out does not run inside `bundle install`, though the sweep still does. It only runs for a `GEM_HOME` outside the per-ABI directories the running ruby keeps to itself, `Gem.default_dir` and `Gem.user_dir`. The hook also fires when `gem repair` or `gem pristine` reinstalls a gem.
|
|
38
75
|
|
|
39
76
|
## Development
|
|
40
77
|
|
|
41
|
-
After checking out the repo, run `bundle install` to install dependencies. Then, you can run `rake test` to run the tests
|
|
78
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, you can run `rake test` to run the tests.
|
|
42
79
|
|
|
43
80
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `gem-repair.gemspec`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [a configured gem server](https://rubygems.org).
|
|
44
81
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module GemRepair
|
|
6
|
+
# Builds the extensions of a freshly installed gem for the other rubies that
|
|
7
|
+
# share its GEM_HOME, so switching rubies does not leave the gem ignored
|
|
8
|
+
# with "extensions are not built".
|
|
9
|
+
#
|
|
10
|
+
# Rubies are looked up under the install roots of mise (MISE_DATA_DIR,
|
|
11
|
+
# default XDG_DATA_HOME/mise) and rbenv (RBENV_ROOT, default ~/.rbenv).
|
|
12
|
+
# GEM_REPAIR_RUBIES narrows them to a comma-separated list of version names.
|
|
13
|
+
# An empty value disables the fan-out.
|
|
14
|
+
module Fanout
|
|
15
|
+
SKIP = /\A(jruby|truffleruby|mruby|rbx)/
|
|
16
|
+
|
|
17
|
+
# Runs inside each target ruby. Exit 2 means there was nothing to do.
|
|
18
|
+
# spec_file pins the lookup to the shared copy the parent just installed.
|
|
19
|
+
# install_extension_in_lib is forced off for the reason in GemRepair::Sweep.
|
|
20
|
+
BUILD = <<~'RUBY'
|
|
21
|
+
$VERBOSE = nil
|
|
22
|
+
exit 2 unless defined?(Gem::Specification) && Gem::Specification.method_defined?(:missing_extensions?)
|
|
23
|
+
name, version, spec_file = ARGV
|
|
24
|
+
spec = Gem::Specification.find_all_by_name(name, version).find { |s| s.spec_file == spec_file }
|
|
25
|
+
exit 2 if spec.nil? || spec.default_gem? || !spec.missing_extensions?
|
|
26
|
+
exit 2 unless spec.required_ruby_version.satisfied_by?(Gem.ruby_version)
|
|
27
|
+
def Gem.install_extension_in_lib; false; end
|
|
28
|
+
begin
|
|
29
|
+
spec.build_extensions
|
|
30
|
+
rescue Gem::InstallError => e
|
|
31
|
+
$stderr.puts e.message
|
|
32
|
+
end
|
|
33
|
+
exit spec.missing_extensions? ? 1 : 0
|
|
34
|
+
RUBY
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def run(spec, ui: Gem::DefaultUserInteraction.ui)
|
|
39
|
+
return if spec.extensions.empty?
|
|
40
|
+
# Bundler autoloads Installer, so only a real load marks bundle install.
|
|
41
|
+
return if defined?(Bundler) && !Bundler.autoload?(:Installer)
|
|
42
|
+
# A per-ABI base_dir is private to this ruby, so the others never read it.
|
|
43
|
+
base_dir = File.realpath(spec.base_dir) rescue File.expand_path(spec.base_dir)
|
|
44
|
+
return if [Gem.default_dir, Gem.user_dir].any? { |dir| (File.realpath(dir) rescue File.expand_path(dir)) == base_dir }
|
|
45
|
+
|
|
46
|
+
env = { "GEM_HOME" => spec.base_dir, "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) }
|
|
47
|
+
# The parent's Bundler setup does not resolve under another ruby.
|
|
48
|
+
%w[BUNDLER_SETUP RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS BUNDLE_GEMFILE BUNDLE_BIN_PATH].each { |key| env[key] = nil }
|
|
49
|
+
# The child sees Gem.path through realpath, the parent may not.
|
|
50
|
+
spec_file = File.realpath(spec.spec_file) rescue spec.spec_file
|
|
51
|
+
rubies.each do |name, ruby|
|
|
52
|
+
system(env, ruby, "-e", BUILD, "--", spec.name, spec.version.to_s, spec_file)
|
|
53
|
+
case $?.exitstatus
|
|
54
|
+
when 0 then ui.say "Built #{spec.full_name} extensions for #{name}"
|
|
55
|
+
when 2 then next
|
|
56
|
+
else ui.alert_warning "Failed to build #{spec.full_name} extensions for #{name}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# [name, path] pairs, one per distinct install, the running ruby excluded.
|
|
62
|
+
def rubies
|
|
63
|
+
names = ENV["GEM_REPAIR_RUBIES"]&.split(",")&.map(&:strip)
|
|
64
|
+
return [] if names&.empty?
|
|
65
|
+
|
|
66
|
+
# Gem.ruby quotes a path that contains a space, RbConfig.ruby does not.
|
|
67
|
+
current = File.realpath(RbConfig.ruby)
|
|
68
|
+
seen = {}
|
|
69
|
+
roots.flat_map { |root, pattern| Dir.glob(pattern, base: root).sort.map { |path| File.join(root, path) } }.filter_map do |ruby|
|
|
70
|
+
name = File.basename(File.dirname(File.dirname(ruby)))
|
|
71
|
+
next if SKIP.match?(name)
|
|
72
|
+
next if names && !names.include?(name)
|
|
73
|
+
|
|
74
|
+
real = begin
|
|
75
|
+
File.realpath(ruby)
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
next if real == current || seen[real]
|
|
80
|
+
|
|
81
|
+
seen[real] = true
|
|
82
|
+
[name, ruby]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def roots
|
|
87
|
+
mise = ENV["MISE_DATA_DIR"] || File.join(ENV["XDG_DATA_HOME"] || File.expand_path("~/.local/share"), "mise")
|
|
88
|
+
rbenv = ENV["RBENV_ROOT"] || File.expand_path("~/.rbenv")
|
|
89
|
+
# Kept apart from the pattern so metacharacters in the roots stay literal.
|
|
90
|
+
[[mise, "installs/ruby/*/bin/ruby"], [rbenv, "versions/*/bin/ruby"]]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module GemRepair
|
|
7
|
+
# Removes the copies of built extensions that RubyGems leaves in the gem's
|
|
8
|
+
# lib next to the per-ABI copy under extensions/. lib comes first on the
|
|
9
|
+
# load path, so every other ruby sharing the GEM_HOME would load the copy
|
|
10
|
+
# built for the installing ruby and fail. RubyGems 3.6 added a gemrc setting
|
|
11
|
+
# to skip the copy, but it stays on by default.
|
|
12
|
+
module Sweep
|
|
13
|
+
DLEXT = ".#{RbConfig::CONFIG["DLEXT"]}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# What gem sweep --aggressive removed on top of the extension copies.
|
|
17
|
+
DEVELOPMENT = %w[test spec features]
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def targets(spec)
|
|
22
|
+
return [] if spec.default_gem? || spec.extensions.empty?
|
|
23
|
+
|
|
24
|
+
# RubyGems copies one tree into both extensions/ and lib, so a file with
|
|
25
|
+
# no counterpart under extensions/ was shipped by the gem itself.
|
|
26
|
+
built = Dir.glob(File.join("*", "*", spec.full_name, "**", "*#{DLEXT}"), base: File.join(spec.base_dir, "extensions"))
|
|
27
|
+
built = built.map { |file| file.split("/", 4).last }
|
|
28
|
+
(spec.full_require_paths - [spec.extension_dir]).flat_map do |path|
|
|
29
|
+
Dir.glob(File.join("**", "*#{DLEXT}"), base: path).select { |file| built.include?(file) }.map { |file| File.join(path, file) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def development_targets(spec)
|
|
34
|
+
return [] if spec.default_gem?
|
|
35
|
+
|
|
36
|
+
root = spec.full_gem_path
|
|
37
|
+
dirs = DEVELOPMENT.map { |dir| File.join(root, dir) } + Dir.glob("**/tmp", base: root).map { |tmp| File.join(root, tmp) }
|
|
38
|
+
dirs.select { |dir| File.directory?(dir) && dirs.none? { |other| dir.start_with?("#{other}/") } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def clean(spec, dry_run: false, aggressive: false, ui: Gem::DefaultUserInteraction.ui)
|
|
42
|
+
paths = targets(spec)
|
|
43
|
+
paths += development_targets(spec) if aggressive
|
|
44
|
+
paths.each do |path|
|
|
45
|
+
if dry_run
|
|
46
|
+
ui.say "Would remove #{path}"
|
|
47
|
+
else
|
|
48
|
+
File.directory?(path) ? FileUtils.rm_r(path) : File.delete(path)
|
|
49
|
+
ui.say "Removed #{path}"
|
|
50
|
+
end
|
|
51
|
+
rescue SystemCallError => e
|
|
52
|
+
ui.alert_warning "Could not remove #{path}: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
require 'zlib'
|
|
1
2
|
require 'rubygems/command'
|
|
2
3
|
require 'rubygems/installer'
|
|
4
|
+
# see rubygems_plugin.rb
|
|
5
|
+
require_relative '../../gem_repair/sweep' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7")
|
|
3
6
|
|
|
4
7
|
|
|
5
8
|
class Gem::Commands::RepairCommand < Gem::Command
|
|
@@ -10,6 +13,21 @@ class Gem::Commands::RepairCommand < Gem::Command
|
|
|
10
13
|
'Number of parallel threads to use (default: 4)') do |value, options|
|
|
11
14
|
options[:jobs] = value
|
|
12
15
|
end
|
|
16
|
+
|
|
17
|
+
add_option('--prune',
|
|
18
|
+
'Uninstall gems whose extensions are still missing after repair') do |value, options|
|
|
19
|
+
options[:prune] = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_option('--aggressive-sweep',
|
|
23
|
+
'Also remove the test, spec, features and tmp directories of every gem') do |value, options|
|
|
24
|
+
options[:aggressive_sweep] = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_option('-n', '--dry-run',
|
|
28
|
+
'Show what would be repaired, removed or uninstalled without changing anything') do |value, options|
|
|
29
|
+
options[:dry_run] = true
|
|
30
|
+
end
|
|
13
31
|
end
|
|
14
32
|
|
|
15
33
|
def arguments # :nodoc:
|
|
@@ -22,7 +40,18 @@ The repair command finds all installed gems that are missing their compiled
|
|
|
22
40
|
extensions and attempts to reinstall them. This can be useful after upgrading
|
|
23
41
|
Ruby or changing system libraries.
|
|
24
42
|
|
|
43
|
+
It also removes the copies of built extensions that older RubyGems leave in
|
|
44
|
+
the lib directory of every gem with extensions, since they shadow the per-ruby
|
|
45
|
+
copy for the other rubies sharing the GEM_HOME.
|
|
46
|
+
|
|
47
|
+
Reinstalling a gem runs the install hooks, so the fan-out to the other rubies
|
|
48
|
+
described in the README happens here too.
|
|
49
|
+
|
|
25
50
|
Use -j to specify the number of parallel threads (default: 4).
|
|
51
|
+
Use --prune to uninstall gems that are still missing extensions after repair.
|
|
52
|
+
Use --aggressive-sweep to also remove the test, spec, features and tmp
|
|
53
|
+
directories of every gem, as gem sweep --aggressive did.
|
|
54
|
+
Use -n to only show what would be done.
|
|
26
55
|
EOF
|
|
27
56
|
end
|
|
28
57
|
|
|
@@ -31,8 +60,15 @@ Use -j to specify the number of parallel threads (default: 4).
|
|
|
31
60
|
end
|
|
32
61
|
|
|
33
62
|
def execute
|
|
63
|
+
dry_run = options[:dry_run]
|
|
34
64
|
say "Searching for gems with missing extensions..."
|
|
35
65
|
|
|
66
|
+
if defined?(GemRepair::Sweep)
|
|
67
|
+
Gem::Specification.each do |spec|
|
|
68
|
+
GemRepair::Sweep.clean(spec, dry_run: dry_run, aggressive: options[:aggressive_sweep], ui: ui)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
36
72
|
specs = Gem::Specification.select do |spec|
|
|
37
73
|
spec.platform == RUBY_ENGINE && spec.respond_to?(:missing_extensions?) && spec.missing_extensions?
|
|
38
74
|
end
|
|
@@ -53,6 +89,12 @@ Use -j to specify the number of parallel threads (default: 4).
|
|
|
53
89
|
|
|
54
90
|
say "Found #{specs.count} gem(s) to repair: #{specs.map(&:full_name).join(', ')}"
|
|
55
91
|
|
|
92
|
+
if dry_run
|
|
93
|
+
specs.each { |spec| say "Would repair #{spec.full_name}" }
|
|
94
|
+
specs.each { |spec| say "Would uninstall #{spec.full_name} unless repaired" } if options[:prune]
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
56
98
|
# Get number of threads from -j option, default to 4
|
|
57
99
|
num_threads = options[:jobs] || 4
|
|
58
100
|
# Running Gem::Installer in multiple threads deadlocks on old Rubies
|
|
@@ -80,6 +122,15 @@ Use -j to specify the number of parallel threads (default: 4).
|
|
|
80
122
|
specs.each { |spec| repair_gem(spec) }
|
|
81
123
|
end
|
|
82
124
|
|
|
125
|
+
# Reinstalling extracts the gem again, and the install hook only sweeps lib.
|
|
126
|
+
if options[:aggressive_sweep] && defined?(GemRepair::Sweep)
|
|
127
|
+
specs.each { |spec| GemRepair::Sweep.clean(spec, aggressive: true, ui: ui) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if options[:prune]
|
|
131
|
+
specs.select(&:missing_extensions?).each { |spec| prune_gem(spec) }
|
|
132
|
+
end
|
|
133
|
+
|
|
83
134
|
say "Gem repair process complete."
|
|
84
135
|
end
|
|
85
136
|
|
|
@@ -114,4 +165,11 @@ Use -j to specify the number of parallel threads (default: 4).
|
|
|
114
165
|
rescue => e
|
|
115
166
|
alert_error "An unexpected error occurred while repairing #{spec.full_name}: #{e.message}\n Backtrace: #{e.backtrace.join("\n ")}"
|
|
116
167
|
end
|
|
168
|
+
|
|
169
|
+
def prune_gem(spec)
|
|
170
|
+
require 'rubygems/uninstaller'
|
|
171
|
+
Gem::Uninstaller.new(spec.name, version: spec.version, install_dir: spec.base_dir, executables: true).uninstall_gem(spec)
|
|
172
|
+
rescue Gem::Exception => e
|
|
173
|
+
alert_error "Failed to uninstall #{spec.full_name}: #{e.message}"
|
|
174
|
+
end
|
|
117
175
|
end
|
data/lib/rubygems_plugin.rb
CHANGED
|
@@ -2,3 +2,19 @@ require 'rubygems/command_manager'
|
|
|
2
2
|
require 'rubygems/commands/repair_command'
|
|
3
3
|
|
|
4
4
|
Gem::CommandManager.instance.register_command :repair
|
|
5
|
+
|
|
6
|
+
# Every ruby sharing the GEM_HOME loads this plugin, including ones below the
|
|
7
|
+
# required_ruby_version. They still get gem repair, just not the hook.
|
|
8
|
+
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7")
|
|
9
|
+
require_relative 'gem_repair/sweep'
|
|
10
|
+
require_relative 'gem_repair/fanout'
|
|
11
|
+
|
|
12
|
+
# Gem::Installer loads this file again when the gem itself is reinstalled.
|
|
13
|
+
unless defined?(GemRepair::HOOKED)
|
|
14
|
+
GemRepair::HOOKED = true
|
|
15
|
+
Gem.post_install do |installer|
|
|
16
|
+
GemRepair::Sweep.clean(installer.spec)
|
|
17
|
+
GemRepair::Fanout.run(installer.spec)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gem-repair
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hiroshi SHIBATA
|
|
@@ -19,6 +19,8 @@ extra_rdoc_files: []
|
|
|
19
19
|
files:
|
|
20
20
|
- LICENSE
|
|
21
21
|
- README.md
|
|
22
|
+
- lib/gem_repair/fanout.rb
|
|
23
|
+
- lib/gem_repair/sweep.rb
|
|
22
24
|
- lib/rubygems/commands/repair_command.rb
|
|
23
25
|
- lib/rubygems_plugin.rb
|
|
24
26
|
homepage: https://github.com/hsbt/gem-repair
|