#!/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 ~/<ignore>
    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 <ignore> 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
