gem-repair 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de1546b65c1f9dd21344f01ffdb924dd21b1d1ad18a837645702b875f331c468
4
- data.tar.gz: 20493791f64c066be2bd29303d7f846ef82d9837f0d271ad13226054f9642a73
3
+ metadata.gz: c060c1e8acac437d6edfaab550b3a43a7bb19bde988e3307900610ce71fe0db2
4
+ data.tar.gz: 2c753428a55544e7486f0acda99ca68868880a9367aeabe0994cd27c893925c3
5
5
  SHA512:
6
- metadata.gz: ac1791aead1e4f8ad1ee21a824a99c0f7bf8d114ed1e12a030d951f3bdfc1bd44dde72e0b79c5958e5ede695a785f286fb47443480356e3cc030b0575d715ae7
7
- data.tar.gz: 974848b02311245c9ae757046b3e2d12d4a6e059c562c0888c05d21a6170bd549ed31a0baa45227ca5835e443784792bc517facf62b0151e5022e6b4c0f0437e
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. Find all installed gems that are missing their compiled C extensions.
37
- 2. Attempt to reinstall these gems using multiple threads.
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 (though no tests are set up in this initial version).
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
 
@@ -101,7 +152,12 @@ Use -j to specify the number of parallel threads (default: 4).
101
152
 
102
153
  # Use spec.cache_file if available and valid, otherwise the installer might re-download
103
154
  # Forcing a specific installer might be needed if default behavior isn't right
104
- installer = Gem::Installer.at(spec.cache_file, installer_options)
155
+ installer = if Gem::Installer.respond_to?(:at)
156
+ Gem::Installer.at(spec.cache_file, installer_options)
157
+ else
158
+ # Gem::Installer.at was added in RubyGems 2.5.0 (Ruby 2.3)
159
+ Gem::Installer.new(spec.cache_file, installer_options)
160
+ end
105
161
  installer.install
106
162
  say "Successfully repaired #{spec.full_name} to #{spec.base_dir}"
107
163
  rescue Gem::Ext::BuildError, Gem::Package::FormatError, Gem::InstallError, Zlib::BufError, NameError => e
@@ -109,4 +165,11 @@ Use -j to specify the number of parallel threads (default: 4).
109
165
  rescue => e
110
166
  alert_error "An unexpected error occurred while repairing #{spec.full_name}: #{e.message}\n Backtrace: #{e.backtrace.join("\n ")}"
111
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
112
175
  end
@@ -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.2.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