#!/bin/sh exec ruby -w -x $0 ${1+"$@"} # -*- ruby -*- #!ruby -w # $Id: p4deltarun.rb,v 1.2 2003/06/18 17:59:17 jeugenepace Exp $ # p4delta: summarizes Perforce changes and executes the appropriate commands # Returns the home directory, for both Unix and Windows. def home_directory if hm = ENV["HOME"] return hm else hd = ENV["HOMEDRIVE"] hp = ENV["HOMEPATH"] if hd || hp return (hd || "") + (hp || "\\") else return nil end end end # Very minimal logging output. If $verbose is set, this displays the method and # line number whence called. module Log @@verbose = false @@width = 0 @@output = $stdout @@fmt = "[%s:%04d] {%s} %s\n" @@align = false def Log.verbose=(v) @@verbose = v end def Log.output=(fname) @@output = File.new(fname, "w") end def Log.set_widths(file_width, line_width, func_width) @@fmt = "[%#{file_width}s:%#{line_width}d] {%#{func_width}s} %s\n" end def Log.align @@align = true end def Log.log(msg) if @@verbose c = caller(1)[0] c.index(/(.*):(\d+)(?::in \`(.*)\')?/) file, line, func = $1, $2, $3 file.sub!(/.*\//, "") if @@align @@width = [ @@width, func.length ].max @@output.printf "[%s:%04d] {%-*s} %s\n", file, line, @@width, func, msg.chomp else @@output.printf @@fmt, file, line, func, msg.chomp end end end def log(msg) if @@verbose c = caller(1)[0] c.index(/(.*):(\d+)(?::in \`(.*)\')?/) file, line, func = $1, $2, $3 func = self.class.to_s + "#" + func file.sub!(/.*\//, "") if @@align @@width = [ @@width, func.length ].max @@output.printf "[%s:%04d] {%-*s} %s\n", file, line, @@width, func, msg.chomp else @@output.printf @@fmt, file, line, func, msg.chomp end end end protected end class P4DeltaOptions include Log attr_accessor :confirm attr_accessor :execute attr_accessor :force_diff attr_accessor :package attr_accessor :quiet attr_accessor :verbose attr_accessor :version def initialize(package = "undef", version = "1.2.3.4") @package = package @version = version @verbose = false # whether to spew debugging output @quiet = false # whether to suppress warnings @confirm = false # whether to confirm remove commands @execute = false # whether to execute @force_diff = false # whether to do a forced-diff end def run read_rcfile read_environment_variable read_options end def read_environment_variable # process the environment variable if ENV["P4DELTAOPTS"] log "reading environment variable" options = ENV["P4DELTAOPTS"].split(/\s+/) while options.length > 0 opt = options.shift Log.log "processing opt " + opt arg = options.shift process_option(opt, options) end end end def read_options log "reading options" while ARGV.length > 0 arg = ARGV.shift log "processing arg #{arg}" if process_option(arg, ARGV) ARGV.unshift(arg) break end end log "done reading options" end # Returns whether the value matches a true value, such as "yes", "true", or # "on". def to_boolean(value) [ "yes", "true", "on" ].include?(value.to_s.downcase) end def process_option(opt, args = nil) opt.gsub!(/^\-+/, "") case opt when "e", "execute" @execute = true when "h", "help" show_help exit when "f", "force" @force_diff = true when "q", "quiet" @quiet = true when "V", "verbose" @verbose = true when "v", "version" print @package, ", version ", @version, "\n" print "Written by Jeff Pace (jpace@incava.org).\n" print "Released under the Lesser GNU Public License.\n" exit 1 else return true end return false end def read_rc_file(rc) log "reading rc file #{rc}" IO.readlines(rc).each do |line| line.sub!(/\s*#.*/, "") line.chomp! name, value = line.split(/\s*[=:]\s*/) next unless name && value case name when "execute" @execute = to_boolean(value) when "quiet" @quiet = to_boolean(value) when "verbose" @verbose = to_boolean(value) when /^force/ @force_diff = to_boolean(value) end end end def read_rcfile # process the rc file if hd = home_directory rc = hd + "/.p4deltarc" Log.log "reading RC file: #{rc}" if File.exists?(rc) read_rc_file(rc) else log "no such file: #{rc}" end end end def show_help puts "USAGE" puts " p4delta [options] directory..." puts "" puts "OPTIONS" puts " -c, --confirm" puts " Confirm deletions from Perforce. Valid only with the execute option." puts "" puts " -e, --execute" puts " Run the add and remove commands for the appropriate files." puts "" puts " -f, --force" puts " Compare all local files against those in Perforce." puts "" puts " -h, --help" puts " Display this help message." puts "" puts " -q, --quiet" puts " Run with minimum output." puts "" puts " -v, --version" puts " Display the version and exit." puts "" puts " -V, --verbose" puts " Run with maximum output." puts "" puts "See the man page for more information." puts "" end end # $Id: p4delta.rb,v 1.2 2003/06/18 17:59:17 jeugenepace Exp $ # p4delta: summarizes Perforce changes and executes the appropriate commands # This program is as identical as possible to cvsdelta. Changes should be kept # in sync require 'getoptlong' require 'find' $verbose = false # whether to spew debugging output $stdout.sync = true # unbuffer output $stderr.sync = true # unbuffer output $PACKAGE = "p4delta" $VERSION = "1.3.2" # A primitive "progress meter", for showing when something time-consuming is # being done. If the global variable $verbose is set, then the tick() method # displays the argument passed. If not set, the tick() method displays the # spinning character with each tick. class ProgressMeter def initialize(verbose) @progress = %w{ | \\ - / } @count = 0 @verbose = verbose end def tick(what = "...") if @verbose # what.chomp! # Log.log "processing #{what}" else print "\r" @count = (@count + 1) % 4 print @progress[@count] end end end # Extended so that we can convert "Unix" (shell, actually) regular expressions # ("*.java") to Ruby regular expressions ("/\.java$/"). class Regexp # shell expressions to Ruby regular expressions @@sh2re = Hash[ '*' => '.*', '?' => '.', '[' => '[', ']' => ']', '.' => '\.', '$' => '\$', '/' => '\/' ] # Returns a regular expression for the given Unix file system expression. def Regexp.unixre_to_string(pat) str = pat.gsub(/(\\.)|(.)/) do if $1 $1 else if @@sh2re.has_key?($2) then @@sh2re[$2] else $2 end end end str end end module FileTester # the percentage of characters that we allow to be odd in a text file ODD_FACTOR = 0.3 # how many bytes (characters) of a file we test TEST_LENGTH = 1024 # extensions associated with files that are always text: KNOWN_TEXT = %w{ txt c cpp mk h hpp html java } # extensions associated with files that are never text: KNOWN_NONTEXT = %w{ a o obj class elc gif gz jar jpg jpeg png pdf tar Z } # returns if the given file is nothing but text (ASCII). def FileTester.text?(file) # Don't waste our time if it doesn't even exist: return false unless File.exists?(file) if file.index(/\.(\w+)\s*$/) suffix = $1 return true if KNOWN_TEXT.include?(suffix) return false if KNOWN_NONTEXT.include?(suffix) end ntested = 0 nodd = 0 f = File.new(file) f.each do |line| # split returns strings, whereas we want characters (bytes) chars = line.split(//, TEST_LENGTH).collect { |w| w[0] } # using the limit parameter to split results in the last character being # "0" (nil), so remove it if chars.size > 1 and chars[-1].to_i == 0 chars = chars[0 .. -2] end chars.each do |ch| ntested += 1 # never allow null in a text file return false if ch.to_i == 0 nodd += 1 unless FileTester.ascii?(ch) return FileTester.summary(nodd, ntested) if ntested >= TEST_LENGTH end end return FileTester.summary(nodd, ntested) end def FileTester.summary(nodd, ntested) return nodd < ntested * ODD_FACTOR end # returns if the given character is ASCII. def FileTester.ascii?(c) # from ctype.h return (c.to_i & ~0x7f) == 0 end end # Additions to the File built-in class. class File include Log # Returns a File::Stat object, or null if there were errors (such as the file # not existing, access denied, etc.). def File.status(fd) begin return File.stat(fd) rescue # ignore files that could not be read, etc. return nil end end # Returns whether the given object is a file. Ignores errors. def File.is_file?(fd) fs = File.status(fd) return fs && fs.file? end # Returns whether the given object is a directory. Ignores errors. def File.is_directory?(fd) fs = File.status(fd) return fs && fs.directory? end # Returns an array containing each of the names for which the associated block # returned true. def File.find_where(dir) names = Array.new Find.find(dir) do |f| names.push(f) if yield(f) end names end # Returns an array of all files under the given directory. def File.find_files(dir) File.find_where(dir) { |f| is_file?(f) } end # Returns an array of all directory under the given directory. def File.find_directories(dir) File.find_where(dir) { |f| is_directory?(f) } end # Returns an array of all files within the given directory. def File.local_files(dir) Dir.new(dir).find_all { |f| is_file?(f) } end # Strips the PWD and the leading ./ def File.clean_name(fname) Log.log "fname: #{fname}" file = fname.dup file.gsub!(Dir.pwd, "") file.gsub!(/^\//, "") file.sub!(/^\.\//, "") file end def File.is_text?(fname) FileTester.text?(fname) end end # A hash that ensures that we use file name of the form: "foo/Bar", not # "./foo/Bar". class FileHash < Hash def []=(f, value) fname = File.clean_name(f) super(fname, value) end def [](f) fname = File.clean_name(f) super(fname) end end # An array that ensures that we use file name of the form: "foo/Bar", not # "./foo/Bar". class FileArray < Array def []=(index, name) file = File.clean_name(f) super(index, name) end def push(name) fname = File.clean_name(name) super(fname) end end # Directories listed so that the parents are first in the list. class OrderedDirectoryList < Array def initialize(files) files.each { |f| add(File.dirname(f)) } end # add a directory def add(dir) if dir && !File.exists?(dir + "/CVS/Entries") # TODO: remove the CVS-icity of this: # attempt to add the parent, unless this is "." # note: this won't work if dir == "." if dir == "." puts "ERROR: Cannot process files from within a directory" puts "not in CVS. Please move up to the parent directory" puts "and retry." exit end add(File.dirname(dir)) pos = index(dir) if pos # nothing to do; dir is already in the list else pdpos = index(File.dirname(dir)) if pdpos # parent already in the list, so insert this dir immediately afterward self[pdpos + 1, 0] = dir else # prepending unshift(dir) end end end end end # Represents .p4ignore files, which are modeled on .cvsignore files. class IgnoredPatterns < Hash include Log def initialize(ignorename) @ignorename = ignorename @dirsread = Array.new end def read(dir) # from the CVS default settings -- ignoring overrides log "reading ignored patterns for " + dir return if @dirsread.include?(dir) @dirsread.push(dir) pats = %w{ CVS *~ .p4ignore .p4config *.o *$ *.BAK *.Z *.a *.bak *.elc *.exe *.ln *.obj *.olb *.old *.orig *.rej *.so . .. .del-* .make.state .nse_depinfo CVS.adm RCS RCSLOG SCCS TAGS _$* core cvslog.* tags } # can't put these guys in the qw() list: ['.#*', '#*', ',*'].each { |p| pats.push(p) } # read ~/ homedir = ENV["HOME"] # unix unless homedir # windows homedir = ENV["HOMEDRIVE"] homepath = ENV["HOMEPATH"] if homepath then if homedir then homedir += homepath else homedir = homepath end end end global = read_ignore_file(homedir) pats.push(*global) unless global.length == 0 # read in the current directory local = read_ignore_file(dir) pats.push(*local) unless local.length == 0 # prepend the current directory to the patterns, contending with the fact # that the directory might actually be a valid regular expression. # wildcard if the pattern is a directory pats = pats.collect do |p| p += "/*" if File.directory?(dir + "/" + p) p end qdir = Regexp.quote(dir) pats = pats.collect do |p| p = Regexp.unixre_to_string(p) qdir + "/" + p end # make a regular expression for each one, to be the entire string (^...$) self[dir] = Array.new pats.each do |p| re = Regexp.new("^" + p + "$") # log "IgnoredPatterns: storing re " + re.source + " for dir " + dir self[dir].push(re) end end def read_ignore_file(dir) pats = Array.new if dir then cifile = dir + "/" + @ignorename if File.exists?(cifile) IO.foreach(cifile) do |line| line.chomp! line.gsub!(/\+/, '\\+') pats.push(*line.split) end else log "no ignore file in " + dir end end pats end # Returns if the file is ignored. Checks the name as both "./name" and "name". def is_ignored?(name) log "is_ignored?(" + name + ")" if name.index("./") == 0 withpref, nopref = name, name.sub!("./", "") else withpref, nopref = "./" + name, name end [ withpref, nopref ].each do |name| dir = name log "dirs = " + keys.join(", ") while dir = File.dirname(dir) if include?(dir) regexps = self[dir] regexps.each do |re| # log "matching " + name + " against " + re.source # stop as soon as we find out it is ignored return true if re.match(name) end else log "dir " + dir + " is not included" end break if dir == "." # else we'll cycle continuously end end return false # it's not ignored end end ## ------------------------------------------------------- ## A file that has changed with respect to the configuration management system. ## # This can be one that has been added (a new file), changed (a previously # ## existing file), or deleted (one that has been removed). ## ------------------------------------------------------- class DeltaFile attr_accessor :adds, :changes, :deletes, :name def initialize(name) # in Ruby, these are primitives, not Objects, so they are not # referencing the same primitive value (i.e., this is just like Java) @adds = @changes = @deletes = 0 @name = File.clean_name(name) end def total @adds + @changes + @deletes end end class ExistingFile < DeltaFile def symbol; "*"; end end class DeletedFile < DeltaFile def initialize(name) super # it would be nice to know how long the file was, i.e., many lines were # deleted end def symbol; "-"; end end class NewFile < DeltaFile def initialize(name) super @adds = IO.readlines(name).length end def symbol; "+"; end end ## ------------------------------------------------------- ## Processing of "diff" output, either contextual ("long form") or unified ## (traditional). ## ------------------------------------------------------- class DiffOutput def initialize(total, regexp) @total = total @regexp = regexp @md = nil end def match(line) @md = @regexp.match(line) end def update(record) nlines = number_of_lines update_record(record, nlines) update_record(@total, nlines) end def to_s self.class.to_s + " " + @regexp.source end end class NormalDiffOutput < DiffOutput def initialize(total, letter) fmt = '(\d+)(?:,(\d+))?' re = Regexp.new("^" + fmt + letter + fmt) super(total, re) end # Returns the amount of lines that changed, based on the MatchData object # which is from standard diff output def number_of_lines from = diff_difference(1, 2) to = diff_difference(3, 4) 1 + [from, to].max end # Returns the difference between the two match data objects, which represent # diff output (3,4c4). def diff_difference(from, to) if @md[to] then @md[to].to_i - @md[from].to_i else 0 end end end class NormalDiffOutputAdd < NormalDiffOutput def initialize(total) super(total, 'a') end def update_record(rec, nlines) rec.adds += nlines end end class NormalDiffOutputChange < NormalDiffOutput def initialize(total) super(total, 'c') end def update_record(rec, nlines) rec.changes += nlines end end class NormalDiffOutputDelete < NormalDiffOutput def initialize(total) super(total, 'd') end def update_record(rec, nlines) rec.deletes += nlines end end class NormalDiffProcessor def initialize(total) name = self.class @addre = NormalDiffOutputAdd.new(total) @delre = NormalDiffOutputDelete.new(total) @chgre = NormalDiffOutputChange.new(total) end def get_tests(line) [ @addre, @chgre, @delre ] end end ## ------------------------------------------------------- ## Perforce-specific code ## ------------------------------------------------------- # A difference within a configuration management system. class P4Delta include Log attr_reader :added, :changed, :deleted, :total def initialize(options, args) log "" @options = options log "set options." # sanity check. p4info = `p4 info 2>&1` log "p4info: #{p4info}" if p4info.index(/command\s+not\s+found/) $stderr.puts "ERROR: p4 executable not in path." exit(2) elsif p4info.index(/Perforce client error:/) if ENV['P4PORT'] $stderr.puts "ERROR: invalid P4PORT: #{ENV['P4PORT']}" else $stderr.puts "ERROR: P4PORT environment variable not set." end exit(2) end # for showing that we're actually doing something @progress = if @options.quiet then nil else ProgressMeter.new(options.verbose) end log "" @ignored_patterns = IgnoredPatterns.new(".p4ignore") log "" log "args: #{args}" @args = if args.length > 0 then args else [ "." ] end end def run @added = FileHash.new @changed = FileHash.new @deleted = FileHash.new @total = DeltaFile.new("total") @warned = Array.new @entries = Array.new @entfiles = Array.new log "" diffprocessor = NormalDiffProcessor.new(@total) curfile = nil # backticks seem to work more consistenty than IO.popen, which was losing # lines from the diff output. dirs = @args.collect do |a| if File.is_file?(a) then File.dirname(a) else a end end dirs.uniq.each { |dir| @ignored_patterns.read(dir) } # directories get appended with "...", Perforce-speak for "*.*" log "args: #{@args}" fileargs = @args.collect { |a| File.is_file?(a) ? a : a + "/..." } cmd = "p4 diff " + (if @options.force_diff then "-f " else "" end) + fileargs.join(" ") + " 2>&1" lines = `#{cmd}` pwd = Dir.pwd @cwdre = Regexp.new(pwd + "/?") lines.each do |line| @progress.tick(line) if @progress log "line: " + line if line.index(/^====.* \- (.*) ====\s*$/) curfile = $1 curfile.sub!(@cwdre, "") else tests = diffprocessor.get_tests(line) tests.each do |re| if re.match(line) log re.to_s + ": matches: " + line rec = get_record(curfile) re.update(rec) else log re.to_s + ": not a match line: " + line end end end end # determine new files cmd = "p4 where . 2>&1" dep2dir = `#{cmd}` mapping = dep2dir.split(/\s+/) @depot = mapping[0] @dir = mapping[-1] cmd = "p4 files " + fileargs.join(" ") + " 2>&1" lines = `#{cmd}` @p4files = Array.new lines.each do |line| line.chomp! next if line.index(/no such file/) fname, status = depot_to_local(line) log "fname = #{fname}" if status == "delete" log "skipping #{fname}" else log "p4file: #{fname}" @p4files.push(fname) end end @args.each do |arg| if File.directory?(arg) dirs = Array.new File.find_files(arg).each do |f| dir = File.dirname(f) unless dirs.include?(dir) @ignored_patterns.read(dir) dirs.push(dir) end add_file(f) end else add_file(arg) end end @p4files.each do |p4f| unless File.exists?(p4f) add_deleted_file(p4f) end end end # Converts the depot file name to a local one, removing the current working # directory from the front of the file name. Returns the name and the current # status. def depot_to_local(dname) @progress.tick(dname) if @progress log "depot_to_local(#{dname})" fname = dname.sub(/\#\d+ +\- +(add|edit|delete)?.*/, "") status = $1 log "fname = #{fname}" log "status = #{status}" cmd = "p4 where \"" + fname + "\"" dep2dir = `#{cmd}` log "dep2dir = #{dep2dir}" depot = nil dir = nil dep2dir.split(/\n/).each do |d2d| mapping = d2d.split(/\s+/) depot = mapping[0] dir = mapping[-1] break if depot && dir && !depot.index(/^\-/) end log "depot = #{depot}" log "dir = #{dir}" log "cwdre = #{@cwdre.source}" log "fname0 = #{fname}" fname.sub!(Regexp.escape(depot), dir) log "fname1 = #{fname}" fname.sub!(@cwdre, "") log "fname2 = #{fname}" [ fname, status ] end # Returns whether the given file is included in Perforce. def is_file_included?(file) @p4files.include?(file) end def add_file(file) fname = File.clean_name(file) if is_file_included?(fname) log "file already in entries: " + fname elsif @ignored_patterns.is_ignored?(fname) log "file is ignored: " + fname else log "adding file: " + fname if File.readable?(fname) unless @added.include?(fname) # don't add it twice @added[fname] = NewFile.new(fname) @total.adds += @added[fname].adds end else unless @warned.include?(fname) puts "not readable: " + fname @warned.push(fname) end end end end def add_deleted_file(file) @deleted[file] = DeletedFile.new(file) end def get_record(file) @changed[file] = ExistingFile.new(file) unless @changed.include?(file) @changed[file] end def execute print "\nEXECUTING COMMANDS\n" print "\n ADDs\n" if @added.length > 0 execute_command(@added.keys, "add") end print "\n EDITs\n" if @changed.length > 0 opened = Array.new `p4 opened`.each do |opline| opline.chomp! fname = depot_to_local(opline)[0] opened.push(fname) end toedit = @changed.keys.select { |c| !opened.include?(c) } execute_command(toedit, "edit") end if @options.confirm dels = @deleted.keys.reject { |name| print "delete " + name + "? " ans = $stdin.readline ans.upcase[0, 1] != 'Y' } else dels = @deleted.keys end print "\n DELETEs\n" execute_command(dels, "delete") end # TODO: fix for spaces in names def execute_command(names, command) if names.size > 0 cmd = "p4 " + command + ' "' + names.join('" "') + '"' print " ", cmd, "\n" system(cmd) else log "no files to " + command end end def print_change_summary puts printf "%-7s %-7s %-7s %-7s %s\n", "total", "added", "changed", "deleted", "file" printf "======= ======= ======= ======= ====================\n" files = Hash.new [ @added, @changed, @deleted ].each do |ary| ary.each do |file, record| files[file] = record end end files.sort.each do |file, record| print_record(record) end printf "------- ------- ------- ------- --------------------\n"; print_record(@total, "Total") end def print_record(rec, name = nil) name = rec.symbol + " " + rec.name unless name [rec.total, rec.adds, rec.changes, rec.deletes].each do |v| printf("%7d ", v) end print name, "\n" end end $stdout.sync = true # unbuffer $stderr.sync = true # unbuffer $stdin.sync = true # unbuffer $PACKAGE = "p4delta" $VERSION = "1.3.2" begin trap("INT") do # This bypasses the stack trace on exit. abort end Log.log "creating options" Log.log "ARGV: #{ARGV}" options = P4DeltaOptions.new($PACKAGE, $VERSION) Log.log "running options" options.run Log.log "done running options" Log.verbose = options.verbose # Log.output = "/tmp/p4delta.log." + Process.pid.to_s Log.set_widths(-12, 5, -35) Log.log "creating p4delta" Log.log "ARGV: #{ARGV}" delta = P4Delta.new(options, ARGV) Log.log "running p4delta" delta.run Log.log "printing change summary" delta.print_change_summary if options.execute delta.execute end rescue => e # show only the message, not the stack trace: $stderr.puts "error: #{e}" end