# Tested by: ../../test/subversion_test.rb $loaded ||= {}; if !$loaded[File.expand_path(__FILE__)]; $loaded[File.expand_path(__FILE__)] = true; require 'fileutils' require 'rexml/document' require 'rexml/xpath' require 'rubygems' gem 'facets', '>=1.8.51' require 'facets/core/kernel/require_local' require 'facets/core/enumerable/uniq_by' require 'facets/core/kernel/silence_stream' require 'facets/core/module/initializer' gem 'qualitysmith_extensions', '>=0.0.7' # Had a lot of trouble getting ActiveSupport to load without giving errors! Eventually gave up on that idea since I only needed it for mattr_accessor and Facets supplies that. #gem 'activesupport' # mattr_accessor #require 'active_support' #require 'active_support/core_ext/module/attribute_accessors' #require 'facets/core/class/cattr' gem 'qualitysmith_extensions' require 'qualitysmith_extensions/module/attribute_accessors' require 'qualitysmith_extensions/module/guard_method' # RSCM is used for some of the abstraction, such as for parsing log messages into nice data structures. It seems like overkill, though, to use RSCM for most things... gem 'rscm' #require 'rscm' #require 'rscm/scm/subversion' require 'rscm/scm/subversion_log_parser' # Wraps the Subversion shell commands for Ruby. module Subversion # True if you want output from svn to be colorized (useful if output is for human eyes, but not useful if using the output programatically) @@color = false mattr_accessor :color mguard_method :with_color!, :@@color # If true, will only output which command _would_ have been executed but will not actually execute it. @@dry_run = false mattr_accessor :dry_run # If true, will print all commands to the screen before executing them. @@print_commands = false mattr_accessor :print_commands mguard_method :print_commands!, :@@print_commands # Adds the given items to the repository. Items may contain wildcards. def self.add(*args) execute "add #{args.join ' '}" end # Sets the svn:ignore property based on the given +patterns+. # Each pattern is both the path (where the property gets set) and the property itself. # For instance: # "log/*.log" would add "*.log" to the svn:ignore property on the log/ directory. # "log" would add "log" to the svn:ignore property on the ./ directory. def self.ignore(*patterns) patterns.each do |pattern| path = File.dirname(pattern) path += '/' if path == '.' pattern = File.basename(pattern) add_to_property 'ignore', path, pattern end nil end def self.unignore(*patterns) raise NotImplementedError end # Adds the given repo URL (http://svn.yourcompany.com/path/to/something) as an svn:externals. # # Options may include: # * +:as+ - overrides the default behavior of naming the checkout based on the last component of the repo path # * +:local_path+ - specifies where to set the externals property. Defaults to './' # def self.externalize(repo_url, options = {}) options[:local_path] ||= './' options[:as] ||= File.basename(repo_url) options[:as] = options[:as].ljust(29) add_to_property 'externals', options[:local_path], "#{options[:as]} #{repo_url}" end def self.export(path_or_url, target) execute "export #{path_or_url} #{target}" end # Removes the given items from the repository and the disk. Items may contain wildcards. def self.remove(*args) execute "rm #{args.join ' '}" end # Removes the given items from the repository and the disk. Items may contain wildcards. # To do: add a :force => true option to remove def self.remove_force(*args) execute "rm --force #{args.join ' '}" end # Removes the given items from the repository BUT NOT THE DISK. Items may contain wildcards. def self.remove_without_delete(*args) # resolve the wildcards before iterating args.collect {|path| Dir[path]}.flatten.each do |path| entries_file = "#{File.dirname(path)}/.svn/entries" File.chmod(0644, entries_file) xmldoc = REXML::Document.new(IO.read(entries_file)) # first attempt to delete a matching entry with schedule == add unless xmldoc.root.elements.delete "//entry[@name='#{File.basename(path)}'][@schedule='add']" # then attempt to alter a missing schedule to schedule=delete entry = REXML::XPath.first(xmldoc, "//entry[@name='#{File.basename(path)}']") entry.attributes['schedule'] ||= 'delete' if entry end # write back to the file File.open(entries_file, 'w') { |f| xmldoc.write f, 0 } File.chmod(0444, entries_file) end end # Reverts the given items in the working copy. Items may contain wildcards. def self.revert(*args) execute "revert #{args.join ' '}" end # Marks the given items as being executable. Items may _not_ contain wildcards. def self.make_executable(*paths) paths.each do |path| self.set_property 'executable', '', path end end def self.make_not_executable(*paths) paths.each do |path| self.delete_property 'executable', path end end # Returns the status of items in the working directories +paths+. Returns the raw output from svn (use split("\n") if you want an array). def self.status(*args) args = ['./'] if args.empty? execute("status #{args.join ' '}") end def self.status_against_server(*args) args = ['./'] if args.empty? self.status('-u', *args) end def self.update(*args) args = ['./'] if args.empty? execute("update #{args.join ' '}") end def self.commit(*args) args = ['./'] if args.empty? execute("commit #{args.join ' '}") end # The output from `svn status` is nicely divided into two "sections": the section which pertains to the current working copy (not # counting externals as part of the working copy) and then the section with status of all of the externals. # This method returns the first section. def self.status_the_section_before_externals(path = './') status = status(path) || '' status.sub!(/(Performing status.*)/m, '') end # Returns an array of externals *items*. These are the actual externals listed in an svn:externals property. # Example: # vendor/a # vendor/b # Where 'vendor' is an ExternalsContainer containing external items 'a' and 'b'. def self.externals_items(path = './') status = status_the_section_before_externals(path) return [] if status.nil? status.select { |line| line =~ /^X/ }.map { |line| # Just keep the filename part line =~ /^X\s+(.+)/ $1 } end # Returns an array of ExternalsContainer objects representing all externals *containers* in the working directory specified by +path+. def self.externals_containers(path = './') # Using self.externals_items is kind of a cheap way to do this, and it results in some redundancy that we have to filter out # (using uniq_by), but it seemed more efficient than the alternative (traversing the entire directory tree and querying for # `svn prepget svn:externals` at each stop to see if the directory is an externals container). self.externals_items(path).map { |external_dir| ExternalsContainer.new(external_dir + '/..') }.uniq_by { |external| external.container_dir } end # Returns the modifications to the working directory or URL specified in +args+. def self.diff(*args) args = ['./'] if args.empty? execute("diff #{"--diff-cmd colordiff" if color?} #{args.join ' '}") end # Parses the output from diff and returns an array of Diff objects. def self.diffs(*args) args = ['./'] if args.empty? raw_diffs = nil with_color! false do raw_diffs = diff(*args) end DiffsParser.new(raw_diffs).parse end # It's easy to get/set properties, but less easy to add to a property. This method uses get/set to simulate add. # It will uniquify lines, removing duplicates. (:todo: what if we want to set a property to have some duplicate lines?) def self.add_to_property(property, path, *new_lines) # :todo: I think it's possible to have properties other than svn:* ... so if property contains a prefix (something:), use it; else default to 'svn:' # Get the current properties lines = self.get_property(property, path).split "\n" puts "Existing lines: #{lines.inspect}" if $debug # Add the new lines, delete empty lines, and uniqueify all elements lines.concat(new_lines).uniq! puts "After concat(new_lines).uniq!: #{lines.inspect}" if $debug lines.delete '' # Set the property puts "About to set propety to: #{lines.inspect}" if $debug self.set_property property, lines.join("\n"), path end # :todo: Stop assuming the svn: namespace. What's the point of a namespace if you only allow one of them? def self.get_property(property, path = './') execute "propget svn:#{property} #{path}" end def self.get_revision_property(property_name, rev) execute("propget --revprop #{property_name} -r #{rev}").chomp end def self.delete_property(property, path = './') execute "propdel svn:#{property} #{path}" end def self.delete_revision_property(property_name, rev) execute("propdel --revprop #{property_name} -r #{rev}").chomp end def self.set_property(property, value, path = './') execute "propset svn:#{property} '#{value}' #{path}" end def self.set_revision_property(property_name, rev) execute("propset --revprop #{property_name} -r #{rev}").chomp end # Gets raw output of proplist command def self.proplist(rev) execute("proplist --revprop -r #{rev}") end # Returns an array of the names of all revision properties currently set on the given +rev+ # Tested by: ../../test/subversion_test.rb:test_revision_properties_names def self.revision_properties_names(rev) raw_list = proplist(rev) raw_list.scan(/^ +([^ ]+)$/).map { |matches| matches.first.chomp } end # Returns an array of RevisionProperty objects (name, value) for revisions currently set on the given +rev+ # Tested by: ../../test/subversion_test.rb:test_revision_properties def self.revision_properties(rev) revision_properties_names(rev).map { |property_name| RevisionProperty.new(property_name, get_revision_property(property_name, rev)) } end def self.make_directory(dir) execute "mkdir #{dir}" end def self.help(*args) execute "help #{args.join(' ')}" end # Returns the raw output from svn log def self.log(*args) args = ['./'] if args.empty? execute "log #{args.join(' ')}" end # Returns the revision number for head. def self.latest_revision(*args) args = ['./'] if args.empty? # The revision returned by svn status -u seems to be a pretty reliable way to get this. Does anyone know of a better way? matches = /Status against revision:\s+(\d+)/m.match(status_against_server(args)) matches && matches[1] end # Returns the revision number for the working directory(/file?) specified by +path+ def self.latest_revision_for_path(path) # The revision returned by svn info seems to be a pretty reliable way to get this. Does anyone know of a better way? matches = info(path).match(/^Revision: (\d+)/) matches && matches[1] end # Returns an array of RSCM::Revision objects def self.revisions(*args) # Tried using this, but it seems to expect you to pass in a starting date or accept the default starting date of right now, which is silly if you actually just want *all* revisions... #@rscm = ::RSCM::Subversion.new #@rscm.revisions #log_output = Subversion.log('-v') log_output = Subversion.log(*(['-v'] + args)) parser = ::RSCM::SubversionLogParser.new(io = StringIO.new(log_output), url = 'http://ignore.me.com') revisions = parser.parse_revisions revisions end def self.info(*args) args = ['./'] if args.empty? execute "info #{args.join(' ')}" end def self.url(path_or_url = './') matches = info(path_or_url).match(/^URL: (.+)/) matches && matches[1] end # :todo: needs some serious unit-testing love def self.base_url(path_or_url = './') matches = info(path_or_url).match(/^Repository Root: (.+)/) matches && matches[1] # It appears that we might need to use this old way (which looks at 'URL'), since there is actually a handy property called "Repository Root" that we can look at. # base_url = nil # needed so that base_url variable isn't local to loop block (and reset during next iteration)! # started_using_dot_dots = false # loop do # matches = /^URL: (.+)/.match(info(path_or_url)) # if matches && matches[1] # base_url = matches[1] # else # break base_url # end # # # Keep going up the path, one directory at a time, until `svn info` no longer returns a URL (will probably eventually return 'svn: PROPFIND request failed') # if path_or_url.include?('/') && !started_using_dot_dots # path_or_url = File.dirname(path_or_url) # else # started_using_dot_dots = true # path_or_url = File.join(path_or_url, '..') # end # #puts 'going up to ' + path_or_url # end end def self.root_url(*args); base_url(*args); end def self.repository_root(*args); base_url(*args); end def self.repository_uuid(path_or_url = './') matches = info(path_or_url).match(/^Repository UUID: (.+)/) matches && matches[1] end # By default, if you query a directory that is scheduled for addition but hasn't been committed yet (node doesn't have a UUID), # then we will still return true, because it is *scheduled* to be under version control. If you want a stricter definition, # and only want it to return true if the file exists in the *repository* (has a UUID)@ then pass strict = true def self.under_version_control?(file = './', strict = false) if strict !!repository_uuid(file) else # (scheduled_for_addition_counts_as_true) !!url(file) end end def self.working_copy_root(directory = './') uuid = repository_uuid(directory) return nil if uuid.nil? loop do # Keep going up, one level at a time, ... new_directory = File.expand_path(File.join(directory, '..')) new_uuid = repository_uuid(new_directory) # Until we get back a uuid that is nil (it's not a working copy at all) or different (you can have a working copy A inside of a different WC B)... break if new_uuid.nil? or new_uuid != uuid directory = new_directory end directory end # The location of the executable to be used def self.executable @@executable ||= ENV['PATH'].split(':').each do |dir| # if File.exist?(executable = "#{dir}/svn") # puts executable # end if File.exist?(executable = "#{dir}/svn") and # `file #{executable}` !~ /ruby/ # We want to wrap the svn command provided by Subversion, not our custom replacement for that. return executable end end # end protected def self.execute(*args) options = args.last.is_a?(Hash) ? args.pop : {} method = options.delete(:method) || :capture command = "#{executable} #{args.join ' '}" actually_execute(method, command) end # This abstraction exists to assist with unit tests. Test cases can simply override this function so that no external commands need to be executed. def self.actually_execute(method, command) if Subversion.dry_run && !$ignore_dry_run_option puts "In execute(). Was about to execute this command via method :#{method}:" p command end if Subversion.print_commands p command end valid_options = [:capture, :exec, :popen] case method when :capture `#{command} 2>&1` when :exec #Kernel.exec *args Kernel.exec command when :system Kernel.system command when :popen # This is just an idea of how maybe we could improve the LATENCY. Rather than waiting until the command completes # (which can take quite a while for svn status sometimes since it has to walk the entire directory tree), why not process # the output from /usr/bin/svn *in real-time*?? # # Unfortunately, it looks like /usr/bin/svn itself might make that impossible. It seems that if it detects that its output is # being redirected to a pipe, it will not yield any output until the command is finished! # # So even though this command gives you output in real-time: # find / | grep . # as does this: # IO.popen('find /', 'r') {|p| line = ""; ( puts line; $stdout.flush ) until !(line = p.gets) } # as does this: # /usr/bin/svn st # # ... as soon as you redirect svn to a *pipe*, it seems to automatically (annoyingly) buffer its output until it's finished: # /usr/bin/svn st | grep . # So when I tried this: # IO.popen('/usr/bin/svn st', 'r') {|p| line = ""; ( puts line; $stdout.flush ) until !(line = p.gets) } # it didn't seem any more responsive than a plain puts `/usr/bin/svn st` ! Frustrating! # IO.popen(command, 'r') do |pipe| line = "" ( puts line; $stdout.flush ) until !(line = pipe.gets) end else raise ArgumentError.new(":method option must be one of #{valid_options.inspect}") end unless (Subversion.dry_run && !$ignore_dry_run_option) end end #----------------------------------------------------------------------------------------------------------------------------- module Subversion RevisionProperty = Struct.new(:name, :value) # Represents an "externals container", which is a directory that has the svn:externals property set to something useful. # Each ExternalsContainer contains a set of "entries", which are the actual directories listed in the svn:externals # property and are "pulled into" the directory. class ExternalsContainer ExternalItem = Struct.new(:name, :repository_path) attr_reader :container_dir attr_reader :entries def initialize(external_dir) @container_dir = File.expand_path(external_dir) @entries = Subversion.get_property("externals", @container_dir) #p @entries end def has_entries? @entries.size > 0 end def entries_structs entries.chomp.enum(:each_line).map { |line| line =~ /^(\S+)\s*(\S+)/ ExternalItem.new($1, $2) } end def to_s entries_structs = entries_structs() longest_item_name = [ entries_structs.map { |entry| entry.name.size }.max, 25 ].max container_dir.bold + "\n" + entries_structs.map { |entry| " * " + entry.name.ljust(longest_item_name + 1) + entry.repository_path + "\n" }.join end def ==(other) self.container_dir == other.container_dir end end # A collection of Diff objects in in file_name => diff format. class Diffs < Hash end class Diff attr_reader :filename, :diff initializer :filename do @diff = '' end def filename_pretty filename.ljust(100).black_on_white end end class DiffsParser class ParseError < Exception; end initializer :raw_diffs @state = nil def parse diffs = Diffs.new current_diff = nil @raw_diffs.each_line do |line| if line =~ /^Index: (.*)$/ current_diff = Diff.new($1) diffs[current_diff.filename] = current_diff #unless current_diff.nil? @state = :immediately_after_filename next end if current_diff.nil? raise ParseError.new("The raw diff input didn't begin with 'Index:'!") end if @state == :immediately_after_filename if line =~ /^===================================================================$/ || line =~ /^---.*\(revision \d+\)$/ || line =~ /^\+\+\+.*\(revision \d+\)$/ || line =~ /^@@ .* @@$/ # Skip next else @state= :inside_the_actual_diff end end if @state == :inside_the_actual_diff current_diff.diff << line else raise ParseError.new("Expected to be in :inside_the_actual_diff state, but was not.") end end diffs.freeze diffs end end end end