Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Add Gem::Commands::PatchCommand and Gem::Patcher #381

Closed
wants to merge 2 commits into from

4 participants

@strzibny

patch command

This adds the patch command to RubyGems. The patch command helps to patch gems without manually opening and rebuilding them. It opens a given .gem file, extracts it, patches it with system patch command, clones its spec, updates the file list and builds the patched gem.

Usage

gem patch [options] name-version.gem PATCH [PATCH ...]

Optionally with -pNUMBER or --strip=NUMBER option that sets the file name strip count to NUMBER (same options as for patch command on Linux machines).

Example

Instead of extracting gem, patching the gem files with patch, manually changing file list in spec and packing it again:

irb(main):001:0> require 'rubygems/package'
=> true
irb(main):002:0> package = Gem::Package.new 'idn-0.0.2.gem'
=> #<Gem::Package:0x00000001e394e0 @gem="idn-0.0.2.gem", @build_time=2012-09-24 13:35:56 +0200, @checksums={}, @contents=nil, @digests={}, @files=nil, @security_policy=nil, @signatures={}, @signer=nil, @spec=nil>
irb(main):008:0> package.extract_files '/home/strzibny/unpacked'
=> nil
irb(main):009:0> patched_gem = package.spec.file_name
=> "idn-0.0.2.gem"
patch -p0 < mypatch.patch
irb(main):012:0> patched_package = Gem::Package.new patched_gem
=> #<Gem::Package:0x00000002aa0710 @gem="idn-0.0.2.gem", @build_time=2012-09-24 13:39:08 +0200, @checksums={}, @contents=nil, @digests={}, @files=nil, @security_policy=nil, @signatures={}, @signer=nil, @spec=nil>
irb(main):013:0> patched_package.spec = package.spec.clone
=> #<Gem::Specification:0x103c658 idn-0.0.2>
irb(main):014:0> patched_package.spec.rubygems_version = '2.0.a'
=> "2.0.a"
irb(main):015:0> Dir.chdir '/home/strzibny/unpacked' do
irb(main):016:1* patched_package.build
irb(main):017:1> end

will be enough to run:

[strzibny@dhcp-25-242 gem-patch]$ gem patch idn-0.0.2.gem rubygem-idn-0.0.2-Fix-for-ruby-1.9.x.patch 

Note: To reproduce this you have to add patched_package.spec.rubygems_version = 2.0.a in lib/rubygems/patcher.rb on line 74. This is because idn gem file uses RubyGems 1.8.

Josef Strzibny added some commits
Josef Strzibny Add Gem::Commands::PatchCommand and Gem::Patcher
This adds the patch command to RubyGems. The patch command helps to patch gems without manually opening and rebuilding them. It opens a given .gem file, extracts it, patches it with system `patch` command, clones its spec, updates the file list and builds the patched gem.
e0331a4
Josef Strzibny Update Manifest.txt 7779d2a
@drbrain
Owner

This seems better as a RubyGems plugin, is there a reason it should be embedded in RubyGems?

See http://rubygems.rubyforge.org/rubygems-update/Gem.html#label-RubyGems+Plugins

@voxik

It would be sad if one day, you wan to install gem-patch plugin only to find it cannot be installed and you need to patch it. There is higher chance that this will never happen, if it becomes part of RubyGems.

@drbrain
Owner

Historically we've asked proposed commands to live in plugins until an overwhelming need to include them in RubyGems is discovered.

As far as I know, patching gems is not something users do often, so this burden has not been met.

@evanphx
Owner

Agreed, this should be a plugin instead of in core.

@evanphx evanphx closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Oct 1, 2012
  1. Add Gem::Commands::PatchCommand and Gem::Patcher

    Josef Strzibny authored
    This adds the patch command to RubyGems. The patch command helps to patch gems without manually opening and rebuilding them. It opens a given .gem file, extracts it, patches it with system `patch` command, clones its spec, updates the file list and builds the patched gem.
Commits on Oct 2, 2012
  1. Update Manifest.txt

    Josef Strzibny authored
This page is out of date. Refresh to see the latest.
View
4 Manifest.txt
@@ -32,6 +32,7 @@ lib/rubygems/commands/lock_command.rb
lib/rubygems/commands/mirror_command.rb
lib/rubygems/commands/outdated_command.rb
lib/rubygems/commands/owner_command.rb
+lib/rubygems/commands/patch_command.rb
lib/rubygems/commands/pristine_command.rb
lib/rubygems/commands/push_command.rb
lib/rubygems/commands/query_command.rb
@@ -82,6 +83,7 @@ lib/rubygems/package/tar_reader/entry.rb
lib/rubygems/package/tar_test_case.rb
lib/rubygems/package/tar_writer.rb
lib/rubygems/package_task.rb
+lib/rubygems/patcher.rb
lib/rubygems/path_support.rb
lib/rubygems/platform.rb
lib/rubygems/psych_additions.rb
@@ -175,6 +177,7 @@ test/rubygems/test_gem_commands_lock_command.rb
test/rubygems/test_gem_commands_mirror.rb
test/rubygems/test_gem_commands_outdated_command.rb
test/rubygems/test_gem_commands_owner_command.rb
+test/rubygems/test_gem_commands_patch_command.rb
test/rubygems/test_gem_commands_pristine_command.rb
test/rubygems/test_gem_commands_push_command.rb
test/rubygems/test_gem_commands_query_command.rb
@@ -209,6 +212,7 @@ test/rubygems/test_gem_package_tar_reader.rb
test/rubygems/test_gem_package_tar_reader_entry.rb
test/rubygems/test_gem_package_tar_writer.rb
test/rubygems/test_gem_package_task.rb
+test/rubygems/test_gem_patch.rb
test/rubygems/test_gem_path_support.rb
test/rubygems/test_gem_platform.rb
test/rubygems/test_gem_rdoc.rb
View
1  lib/rubygems/command_manager.rb
@@ -77,6 +77,7 @@ def initialize
register_command :mirror
register_command :outdated
register_command :owner
+ register_command :patch
register_command :pristine
register_command :push
register_command :query
View
54 lib/rubygems/commands/patch_command.rb
@@ -0,0 +1,54 @@
+require "rubygems/command"
+require "rubygems/patcher"
+
+class Gem::Commands::PatchCommand < Gem::Command
+ def initialize
+ super "patch", "Patches the gem with the given patches and generates patched gem.",
+ :output => Dir.pwd, :strip => 0
+
+ # Same as 'patch -pNUMBER' on Linux machines
+ add_option('-pNUMBER', '--strip=NUMBER', 'Set the file name strip count to NUMBER.') do |number, options|
+ options[:strip] = number
+ end
+ end
+
+ def arguments # :nodoc:
+ args = <<-EOF
+ GEMFILE path to the gem file to patch
+ PATCH [PATCH ...] list of patches to apply
+ EOF
+ return args.gsub(/^\s+/, '')
+ end
+
+ def description # :nodoc:
+ <<-EOF
+ The patch command helps to patch gems without manually opening and rebuilding them.
+ It opens a given .gem file, extracts it, patches it with system `patch` command,
+ clones its spec, updates the file list and builds the patched gem.
+ EOF
+ end
+
+ def usage # :nodoc:
+ "#{program_name} GEMFILE PATCH [PATCH ...]"
+ end
+
+ def execute
+ gemfile = options[:args].shift
+ patches = options[:args]
+
+ # No gem
+ unless gemfile
+ raise Gem::CommandLineError,
+ "Please specify a gem file on the command line (e.g. gem patch foo-0.1.0.gem PATCH [PATCH ...])"
+ end
+
+ # No patches
+ if patches.empty?
+ raise Gem::CommandLineError,
+ "Please specify patches to apply (e.g. gem patch foo-0.1.0.gem foo.patch bar.patch ...)"
+ end
+
+ patcher = Gem::Patcher.new(gemfile, options[:output])
+ patcher.patch_with(patches, options[:strip])
+ end
+end
View
124 lib/rubygems/patcher.rb
@@ -0,0 +1,124 @@
+require "rbconfig"
+require "tmpdir"
+require "rubygems/package"
+
+class Gem::Patcher
+ include Gem::UserInteraction
+
+ class PatchCommandMissing < StandardError; end
+
+ def initialize(gemfile, output_dir)
+ @gemfile = gemfile
+ @output_dir = output_dir
+
+ # @target_dir is a temporary directory where the gem files live
+ tmpdir = Dir.mktmpdir
+ basename = File.basename(gemfile, '.gem')
+ @target_dir = File.join(tmpdir, basename)
+ end
+
+ ##
+ # Patch the gem, move the new patched gem to the working directory and return the path
+
+ def patch_with(patches, strip_number)
+ check_patch_command_is_installed
+ extract_gem
+
+ # Apply all patches
+ patches.each do |patch|
+ info 'Applying patch ' + patch
+ apply_patch(patch, strip_number)
+ end
+
+ build_patched_gem
+
+ # Move the newly generated gem to the working directory
+ gem_file = IO.read(File.join @target_dir, @package.spec.file_name)
+
+ File.open(File.join(@output_dir, @package.spec.file_name), 'w') do |f|
+ f.write gem_file
+ end
+
+ # Return the path to the patched gem
+ File.join @output_dir, "#{@package.spec.file_name}"
+ end
+
+ def apply_patch(patch, strip_number)
+ patch_path = File.expand_path(patch)
+ info 'Path to the patch to apply: ' + patch_path
+
+ # Apply the patch by calling 'patch -pNUMBER < patch'
+ Dir.chdir @target_dir do
+ if system("patch --verbose -p#{strip_number} < #{patch_path}")
+ info 'Succesfully patched by ' + patch
+ else
+ info 'Error: Unable to patch with ' + patch
+ end
+ end
+ end
+
+ private
+
+ def extract_gem
+ @package = Gem::Package.new @gemfile
+
+ # Unpack
+ info "Unpacking gem '#{@gemfile}' in " + @target_dir
+ @package.extract_files @target_dir
+ end
+
+ def build_patched_gem
+ patched_package = Gem::Package.new @package.spec.file_name
+ patched_package.spec = @package.spec.clone
+ patched_package.spec.files = files_in_gem
+
+ # Change dir and build the patched gem
+ Dir.chdir @target_dir do
+ patched_package.build false
+ end
+ end
+
+ def info(msg)
+ say msg if Gem.configuration.verbose
+ end
+
+ def files_in_gem
+ files = []
+
+ Dir.foreach(@target_dir) do |file|
+ if File.directory? File.join @target_dir, file
+ files += files_in_dir(file) unless /\./.match(file)
+ else
+ files << file
+ end
+ end
+
+ delete_original_files(files)
+ end
+
+ def files_in_dir(dir)
+ files = []
+
+ Dir.foreach(File.join @target_dir, dir) do |file|
+ if File.directory? File.join @target_dir, dir, file
+ files += files_in_dir(File.join dir, file) unless /\./.match(file)
+ else
+ files << File.join(dir, file)
+ end
+ end
+
+ files
+ end
+
+ def delete_original_files(files)
+ files.each do |file|
+ files.delete file if /\.orig/.match(file)
+ end
+ end
+
+ def check_patch_command_is_installed
+ unless system("patch --version")
+ raise PatchCommandMissing, 'Calling `patch` command failed. Do you have it installed?'
+ end
+ end
+end
View
34 test/rubygems/test_gem_commands_patch_command.rb
@@ -0,0 +1,34 @@
+require "rubygems/test_case"
+require "rubygems/commands/patch_command"
+
+class TestGemCommandsPatchCommand < Gem::TestCase
+ def setup
+ super
+
+ @command = Gem::Commands::PatchCommand.new
+ end
+
+ def test_execute_no_gemfile
+ @command.options[:args] = []
+
+ e = assert_raises Gem::CommandLineError do
+ use_ui @ui do
+ @command.execute
+ end
+ end
+
+ assert_match 'Please specify a gem file on the command line (e.g. gem patch foo-0.1.0.gem PATCH [PATCH ...])', e.message
+ end
+
+ def test_execute_no_patch
+ @command.options[:args] = ['Gemfile.gem']
+
+ e = assert_raises Gem::CommandLineError do
+ use_ui @ui do
+ @command.execute
+ end
+ end
+
+ assert_match 'Please specify patches to apply (e.g. gem patch foo-0.1.0.gem foo.patch bar.patch ...)', e.message
+ end
+end
View
285 test/rubygems/test_gem_patch.rb
@@ -0,0 +1,285 @@
+require "rubygems/test_case"
+require "rubygems/patcher"
+
+class TestGemPatch < Gem::TestCase
+ def setup
+ super
+
+ @gems_dir = File.join @tempdir, 'gems'
+ @lib_dir = File.join @tempdir, 'gems', 'lib'
+ FileUtils.mkdir_p @lib_dir
+ end
+
+ ##
+ # Test changing a file in a gem with -p1 option
+
+ def test_change_file_patch
+ gemfile = bake_testing_gem
+
+ patches = []
+ patches << bake_change_file_patch
+
+ # Creates new patched gem in @gems_dir
+ patcher = Gem::Patcher.new(gemfile, @gems_dir)
+ patched_gem = patcher.patch_with(patches, 1)
+
+ # Unpack
+ package = Gem::Package.new patched_gem
+ package.extract_files @gems_dir
+
+ assert_equal patched_file, file_contents('foo.rb')
+ end
+
+ ##
+ # Test adding a file into a gem with -p0 option
+
+ def test_new_file_patch
+ gemfile = bake_testing_gem
+
+ patches = []
+ patches << bake_new_file_patch
+
+ # Create a new patched gem in @gems_fir
+ patcher = Gem::Patcher.new(gemfile, @gems_dir)
+ patched_gem = patcher.patch_with(patches, 0)
+
+ # Unpack
+ package = Gem::Package.new patched_gem
+ package.extract_files @gems_dir
+
+ assert_equal original_file, file_contents('bar.rb')
+ end
+
+ ##
+ # Test adding and deleting a file in a gem with -p0 option
+
+ def test_delete_file_patch
+ gemfile = bake_testing_gem
+
+ patches = []
+ patches << bake_new_file_patch
+ patches << bake_delete_file_patch
+
+ # Create a new patched gem in @gems_fir
+ patcher = Gem::Patcher.new(gemfile, @gems_dir)
+ patched_gem = patcher.patch_with(patches, 0)
+
+ # Unpack
+ package = Gem::Package.new patched_gem
+ package.extract_files @gems_dir
+
+ # Only foo.rb should stay in /lib, bar.rb should be gone
+ assert_raises(RuntimeError, 'File not found') {
+ file_contents(File.join @lib_dir, 'bar.rb')
+ }
+ end
+
+ ##
+ # Incorrect patch, nothing happens
+
+ def test_gem_should_not_change
+ gemfile = bake_testing_gem
+
+ patches = []
+ patches << bake_incorrect_patch
+
+ # Create a new patched gem in @gems_fir
+ patcher = Gem::Patcher.new(gemfile, @gems_dir)
+ patched_gem = patcher.patch_with(patches, 0)
+
+ # Unpack
+ package = Gem::Package.new patched_gem
+ package.extract_files @gems_dir
+
+ assert_equal original_file, file_contents('foo.rb')
+ assert_equal original_gemspec, current_gemspec
+ end
+
+ def bake_change_file_patch
+ patch_path = File.join(@gems_dir, 'change_file.patch')
+
+ File.open(patch_path, 'w') do |f|
+ f.write change_file_patch
+ end
+
+ patch_path
+ end
+
+ def bake_new_file_patch
+ patch_path = File.join(@gems_dir, 'new_file.patch')
+
+ File.open(patch_path, 'w') do |f|
+ f.write new_file_patch
+ end
+
+ patch_path
+ end
+
+ def bake_delete_file_patch
+ patch_path = File.join(@gems_dir, 'delete_file.patch')
+
+ File.open(patch_path, 'w') do |f|
+ f.write delete_file_patch
+ end
+
+ patch_path
+ end
+
+ def bake_incorrect_patch
+ patch_path = File.join(@gems_dir, 'incorrect.patch')
+
+ File.open(patch_path, 'w') do |f|
+ f.write incorrect_patch
+ end
+
+ patch_path
+ end
+
+ def bake_original_gem_files
+ # Create /lib/foo.rb
+ file_path = File.join(@lib_dir, 'foo.rb')
+
+ File.open(file_path, 'w') do |f|
+ f.write original_file
+ end
+
+ # Create .gemspec file
+ gemspec_path = File.join(@gems_dir, 'foo-0.gemspec')
+
+ File.open(gemspec_path, 'w') do |f|
+ f.write original_gemspec
+ end
+ end
+
+ def bake_testing_gem
+ bake_original_gem_files
+
+ test_package = Gem::Package.new 'foo-0.gem'
+ test_package.spec = Gem::Specification.load(File.join(@gems_dir, 'foo-0.gemspec'))
+
+ # Build
+ Dir.chdir @gems_dir do
+ test_package.build false
+ end
+
+ File.join(@gems_dir, 'foo-0.gem')
+ end
+
+ def current_gemspec
+ gemspec_path = File.join(@gems_dir, 'foo-0.gemspec')
+
+ IO.read(gemspec_path)
+ end
+
+ ##
+ # Get the content of the given file in @lib_dir
+
+ def file_contents(file)
+ file_path = File.join(@lib_dir, file)
+
+ begin
+ file_content = IO.read(file_path)
+ rescue
+ raise RuntimeError, 'File not found'
+ end
+
+ file_content
+ end
+
+ def original_gemspec
+ <<-EOF
+ Gem::Specification.new do |s|
+ s.platform = Gem::Platform::RUBY
+ s.name = 'foo'
+ s.version = 0
+ s.author = 'A User'
+ s.email = 'example@example.com'
+ s.homepage = 'http://example.com'
+ s.summary = "this is a summary"
+ s.description = "This is a test description"
+ s.files = ['lib/foo.rb']
+ end
+ EOF
+ end
+
+ def original_file
+ <<-EOF
+ module Foo
+ def bar
+ 'Original'
+ end
+ end
+ EOF
+ end
+
+ def patched_file
+ <<-EOF
+ module Foo
+ class Bar
+ def foo_bar
+ 'Patched'
+ end
+ end
+ end
+ EOF
+ end
+
+ def change_file_patch
+ <<-EOF
+ diff -u a/lib/foo.rb b/lib/foo.rb
+ --- a/lib/foo.rb
+ +++ b/lib/foo.rb
+ @@ -1,6 +1,8 @@
+ module Foo
+ - def bar
+ - 'Original'
+ + class Bar
+ + def foo_bar
+ + 'Patched'
+ + end
+ end
+ end
+ EOF
+ end
+
+ def new_file_patch
+ <<-EOF
+ diff lib/bar.rb lib/bar.rb
+ --- /dev/null
+ +++ lib/bar.rb
+ @@ -0,0 +1,5 @@
+ + module Foo
+ + def bar
+ + 'Original'
+ + end
+ + end
+ EOF
+ end
+
+ def delete_file_patch
+ <<-EOF
+ diff lib/bar.rb lib/bar.rb
+ --- lib/bar.rb
+ +++ /dev/null
+ @@ -1,5 +0,0 @@
+ - module Foo
+ - def bar
+ - 'Original'
+ - end
+ - end
+ EOF
+ end
+
+ def incorrect_patch
+ <<-EOF
+ diff lib/foo.rb lib/foo.rb
+ --- lib/foo.rb
+ +++ /dev/null
+ - module Foo
+ - def bar
+ - 'Original'
+ - end
+ - end
+ EOF
+ end
+end
Something went wrong with that request. Please try again.