#!/usr/bin/ruby -w
# -*- ruby -*-
#!ruby -w

# svndelta: summarizes Subversion changes and executes the appropriate commands

require 'pathname'
require 'rexml/document'
require 'singleton'
require 'find'
require 'optparse'

# munge the load path so we can easily reference the diff module (which is under
# svndelta).

$USE_DIFF_LCS = false

if $USE_DIFF_LCS
  dir = File.dirname(File.expand_path(__FILE__))
  $:.unshift dir
  
  require 'diff/lcs'
end

$PACKAGE = "svndelta"
$VERSION = "1.0.6"

$stdout.sync = true             # unbuffer output
$stderr.sync = true             # unbuffer output
$stdin.sync  = true             # unbuffer input

module Enumerable

  def collect_with_index(&blk)
    ary = []
    self.each_with_index do |item, idx|
      ary << blk.call(item, idx)
    end
    ary
  end
  
  def select_with_index(&blk)
    ary = []
    self.each_with_index do |item, idx|
      ary << item if blk.call(item, idx)
    end
    ary
  end
  
end

class Dir

  # Returns the home directory, for both Unix and Windows.
  def self.home
    ENV["HOME"] || begin
                     hd = ENV["HOMEDRIVE"]
                     hp = ENV["HOMEPATH"]
                     if hd || hp
                       (hd || "") + (hp || "\\")
                     end
                   end
  end
  
end

module FileTester

  # the percentage of characters that we allow to be odd in a text file
  ODD_LIMIT = 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?(f)
    fname = f.to_s
    
    # Don't waste our time if it doesn't even exist:
    return false unless File.exists?(fname)
    
    if fname.index(%r{\.(\w+)\s*$})
      suffix = $1
      return true  if KNOWN_TEXT.include?(suffix)
      return false if KNOWN_NONTEXT.include?(suffix)
    end
    
    ntested = 0
    nodd = 0
    File.open(fname) do |file|
      bytes = file.read(TEST_LENGTH)
      
      if bytes
        oddlimit = bytes.length * ODD_LIMIT

        bytes.each_byte do |byte|

          # never allow null in a text file
          return false if byte.to_i == 0
          
          if !FileTester.ascii?(byte)
            oddlimit -= 1
            return false if oddlimit <= 0
          end
        end
      end
    end
    true
  end

  def FileTester.summary(nodd, ntested)
    return nodd < ntested * ODD_LIMIT
  end

  # returns if the given character is ASCII.
  def FileTester.ascii?(c)
    # from ctype.h
    return (c.to_i & ~0x7f) == 0
  end

end


class Regexp

  # shell expressions to Ruby regular expression sequences
  SH2RE = Hash[
    '*'  => '.*', 
    '?'  => '.',
    '['  => '\[',
    ']'  => '\]',
    '.'  => '\.',
    '$'  => '\$',
    '/'  => '\/',
    '('  => '\(',
    ')'  => '\)',
  ]

  # Returns a regular expression for the given Unix file system expression.
  
  def self.unixre_to_string(pat)
    pat.gsub(%r{(\\.)|(.)}) do
      $1 || SH2RE[$2] || $2
    end
  end

end


class Log

  module Severity
    DEBUG = 0
    INFO  = 1
    WARN  = 2
    ERROR = 3
    FATAL = 4
  end
  
  include Log::Severity

  attr_accessor :level

  def initialize
    @level = FATAL
    @width = 0
    @output = $stderr
    @fmt = "[%s:%04d] {%s}"
  end
  
  def verbose=(v)
    @level = v ? DEBUG : FATAL
  end

  # Creates a printf format for the given widths, for aligning output.
  def set_widths(file_width, line_width, func_width)
    @fmt = "[%#{file_width}s:%#{line_width}d] {%#{func_width}s}"
  end

  %w{ DEBUG INFO WARN ERROR FATAL }.each do |sev|
    meth = "def #{sev.downcase}(msg = \"\", depth = 1, &blk); log(msg, #{sev}, depth + 1, &blk); end"
    module_eval meth
  end

  # Logs the given message.
  def log(msg = "", level = DEBUG, depth = 1, cname = nil, &blk)
    if level >= @level
      c = caller(depth)[0]
      c.index(%r{(.*):(\d+)(?::in \`(.*)\')?})
      file, line, func = $1, $2, $3 || "???"
      file.sub!(%r{.*\/}, "")

      if cname
        func.insert(0, cname + "#")
      end

      print_formatted(file, line, func, msg, level, &blk)
    end
  end

  # Shows the current stack.
  def stack(msg = "", level = DEBUG, depth = 1, cname = nil, &blk)
    if level >= @level
      stk = caller(depth)
      for c in stk
        c.index(%r{(.*):(\d+)(?::in \`(.*)\')?})
        file, line, func = $1, $2, $3 || "???"
        file.sub!(%r{.*\/}, "")

        if cname
          func.insert(0, cname + "#")
        end
        
        print_formatted(file, line, func, msg, level, &blk)
        msg = ""
      end
    end
  end

  def print_formatted(file, line, func, msg, level, &blk)
    hdr = sprintf @fmt, file, line, func
    print(hdr, msg, level, &blk)
  end
  
  def print(hdr, msg, level, &blk)
    if blk
      x = blk.call
      if x.kind_of?(String)
        msg = x
      else
        return
      end
    end

    @output.puts hdr + " " + msg.to_s.chomp
  end

  # by default, class methods delegate to a single app-wide log.

  @@log = Log.new

  def Log.verbose=(*args)
    @@log.verbose = *args
  end
  
  def Log.level=(lvl)
    @@log.level = lvl
  end

  def Log.level
    @@log.level
  end

  # Creates a printf format for the given widths, for aligning output.
  def Log.set_widths(*args)
    @@log.set_widths(*args)
  end
  
  %w{ DEBUG INFO WARN ERROR FATAL }.each do |sev|
    meth = "def Log.#{sev.downcase}(msg = \"\", depth = 1, &blk); @@log.log(msg, #{sev}, depth + 1, &blk); end"
    module_eval meth
  end

  # Logs the given message.
  def Log.log(msg = "", level = DEBUG, depth = 1, cname = nil, &blk)
    @@log.log(msg, level, depth + 1, cname, &blk)
  end

  def Log.stack(msg = "", level = DEBUG, depth = 1, cname = nil, &blk)
    @@log.stack(msg, level, depth, cname, &blk)
  end

end

module Loggable

  %w{ DEBUG INFO WARN ERROR FATAL }.each do |sev|
    meth = "def #{sev.downcase}(msg = \"\", depth = 1, &blk); Log.log(msg, Log::#{sev}, depth + 1, self.class.to_s, &blk); end"
    module_eval meth
  end

  def log(msg = "", level = Log::DEBUG, depth = 1, &blk)
    Log.log(msg, level, depth + 1, self.class.to_s)
  end
  
  def stack(msg = "", level = Log::DEBUG, depth = 1, &blk)
    Log.stack(msg, level, depth + 1, self.class.to_s, &blk)
  end

end


module Text

  # Highlights text using either ANSI terminal codes, or HTML.

  # Note that the foreground and background sections can have modifiers
  # (attributes).
  # 
  # Examples:
  #     black
  #     blue on white
  #     bold green on yellow
  #     underscore bold magenta on cyan
  #     underscore red on cyan
  
  class Highlighter

    VERSION = "1.0.4"

    COLORS      = %w{ black red green yellow blue magenta cyan white }
    DECORATIONS = %w{ none reset bold underscore underline blink reverse }

    COLORS_RE = Regexp.new('(?: ' + 
                                # background will be in capture 0
                                'on(?:\s+|_) ( ' + COLORS.join(' | ') + ' ) | ' +
                                # foreground will be in capture 1
                                '( ' + (COLORS + DECORATIONS).join(' | ') + ' ) ' +
                            ')', Regexp::EXTENDED);

    DEFAULT_COLORS = [
      "black on yellow",
      "black on green",
      "black on magenta",
      "yellow on black",
      "magenta on black",
      "green on black",
      "cyan on black",
      "blue on yellow",
      "blue on magenta",
      "blue on green",
      "blue on cyan",
      "yellow on blue",
      "magenta on blue",
      "green on blue",
      "cyan on blue",
    ]
    
    def self.parse_colors(str)
      str.scan(Regexp.new(COLORS_RE)).collect do |color|
        color[0] ? "on_" + color[0] : color[1]
      end
    end

    def initialize(colors)
      @colors = colors
    end

    def highlight(str)
      pre + str + post
    end

    def to_s
      (@colors || '').join(' ')
    end

  end

  # Highlights using ANSI escape sequences.

  class ANSIHighlighter < Highlighter

    ATTRIBUTES = Hash[
      'none'       => '0', 
      'reset'      => '0',
      'bold'       => '1',
      'underscore' => '4',
      'underline'  => '4',
      'blink'      => '5',
      'reverse'    => '7',
      'concealed'  => '8',
      'black'      => '30',
      'red'        => '31',
      'green'      => '32',
      'yellow'     => '33',
      'blue'       => '34',
      'magenta'    => '35',
      'cyan'       => '36',
      'white'      => '37',
      'on_black'   => '40',
      'on_red'     => '41',
      'on_green'   => '42',
      'on_yellow'  => '43',
      'on_blue'    => '44',
      'on_magenta' => '45',
      'on_cyan'    => '46',
      'on_white'   => '47',
    ]

    RESET = "\e[0m"

    def self.make(str)
      colors = parse_colors(str)
      ANSIHighlighter.new(colors)
    end

    def initialize(colors)
      super
      @code = nil
      @reset = RESET
    end

    # Returns the escape sequence for the given name.

    def name_to_code(nm)
      "\e[#{ATTRIBUTES[nm]}m"
    end

    def highlight(str)
      @code ||= begin
                  @code = @colors.collect do |color|
                    name_to_code(color)
                  end.join("")
                end
      
      @code + str + RESET
    end

  end
  
end


module SVN

  class IgnoreFiles
    include Loggable

    OLDDEFAULT_PATTERNS =  %w{
                           *~
                           .svnignore
                           *.o
                           *$
                           *.BAK
                           *.Z
                           *.a
                           *.bak
                           *.elc
                           *.exe
                           *.ln
                           *.obj
                           *.olb
                           *.old
                           *.orig
                           *.rej
                           *.so
                           .
                           ..
                           .del-*
                           .make.state
                           .nse_depinfo
                           TAGS
                           _$*
                           core
                           tags
                          }
    
    DEFAULT_PATTERNS =  %w{
                           *~
                           .svnignore
                           *.o
                           *.BAK
                           *.Z
                           *.a
                           *.bak
                           *.obj
                           *.so
                          }
    
    # can't put these guys in the qw() list:
    DEFAULT_PATTERNS.concat(['.#*', '#*', ',*'])

    @@self = nil

    def self.instance
      @@self ||= self.new
    end

    def self.reset
      @@self = self.new
    end
    
    def initialize
      @patterns = Hash.new

      syspatterns = DEFAULT_PATTERNS.collect { |x| Regexp.new(Regexp.unixre_to_string(x) + '\z') }

      add_patterns('/', syspatterns)
      
      global = IgnoreFile.new(Dir.home)
      add_file(global)
    end
    
    def add_file(igfile)
      log "igfile: #{igfile}"
      add_patterns(igfile.dir, igfile.patterns)
    end

    def add_patterns(dir, patterns)
      log "dir: #{dir}; patterns: #{patterns.inspect}"
      
      p = Pathname.new(dir).expand_path
      (@patterns[p] ||= []).concat(patterns)
    end

    # matches against the file name, lazily (late) reading .svnignore
    # files in the directories up the fname hierarchy.
    
    def match?(fname, lazy_read = true)
      log "fname: #{fname}"

      pname = Pathname.new(fname).expand_path

      # we match directories and files:
      dir = pname.directory? ? pname : pname.dirname
      
      log "dir: #{dir}; patterns: #{@patterns[dir].inspect}"

      # test all directories up to the root (associated with built-ins)
      # and also the user's own .svnignore file (in their home directory).

      loop do
        # read this if we haven't already
        if lazy_read && !@patterns[dir]
          add_file(IgnoreFile.new(dir))
        end
          
        return true if pattern_match?(dir, fname)
        break       if dir.root?
        dir = dir.dirname
      end

      # read the settings from the home directory until we already went through it:
      return !(pname.to_s.match(Dir.home.to_s) || !pattern_match?(Dir.home, fname))
    end

    private
    def pattern_match?(dir, fname)
      (@patterns[dir] || []).any? { |pat| pat.match(fname) }
    end
  end

  class IgnoreFile
    include Loggable

    attr_reader :patterns, :dir
    
    def initialize(dir, io = nil)
      @patterns = Array.new
      @dir = dir
      
      unless io
        fname = Pathname.new(dir) + '.svnignore'
        if fname.exist?
          io = File.open(fname)
        end
      end

      if io
        io.readlines.each do |line|
          line.chomp!
          line.strip!
          unless line.empty?
            @patterns << Regexp.new(Regexp.unixre_to_string(line) + '\z')
          end
        end
      end
    end
  end

  class DeltaEntry
    attr_accessor :nadded
    attr_accessor :nchanged
    attr_accessor :ndeleted
    attr_accessor :executed

    def initialize
      @nadded = @nchanged = @ndeleted = 0
      @executed = false
    end
  end

  class DeletedEntry < DeltaEntry
  end

  class AddedEntry < DeltaEntry
  end
  
  class ChangedEntry < DeltaEntry
  end

  class Differ
    attr_accessor :added, :deleted, :changed

    @@diffs = nil

    def initialize(filename)
      unless @@diffs
        cmd = "svn diff"

        @@diffs = Hash.new

        @added = @changed = @deleted = 0
        
        @ndel = @nadd = nil

        @fname = nil
        fnamere = Regexp.new('^Index:\s*(\S+)')
        
        IO.popen(cmd) do |file|
          file.each do |line|
            if md = fnamere.match(line)
              close_file
              @fname = md[1]
            else
              case line[0, 1]
              when "+"
                @nadd = (@nadd || 0) + 1
              when "-"
                @ndel = (@ndel || 0) + 1
              else
                close_block
              end
            end
          end
        end

        close_file
      end
        
      @added = @changed = @deleted = 0
      if diff = @@diffs[filename]
        @added   = diff[0]
        @changed = diff[1]
        @deleted = diff[2]
      end
    end

    def close_file
      close_block

      if @fname
        @@diffs[Pathname.new(@fname)] = [ @added, @changed, @deleted ]
      end

      @added = @changed = @deleted = 0
    end
    
    def close_block
      if @ndel && @nadd
        @changed += [ @ndel, @nadd ].max
      elsif @nadd
        @added   += @nadd
      elsif @ndel
        @deleted += @ndel
      end
      @ndel = @nadd = nil
    end
    
  end

  module Delta
    class Options
      include Loggable

      attr_accessor :banner
      attr_accessor :color
      attr_accessor :colors
      attr_accessor :confirm
      attr_accessor :execute
      attr_accessor :package
      attr_accessor :prune
      attr_accessor :quiet
      attr_accessor :recurse
      attr_accessor :verbose
      attr_accessor :version
      
      attr_accessor :diff

      def initialize(package, version)
        @package     = package
        @version     = version
        @verbose     = false        # whether to spew debugging output
        @quiet       = false        # whether to suppress warnings
        @color       = false        # whether to use colors in display
        @confirm     = false        # whether to confirm remove commands
        @changes     = true         # whether to show files that changed
        @execute     = false        # whether to execute
        @banner      = true
        @recurse     = true
        @prune       = false        # whether to skip directories that are not in Subversion
        @colors      = nil
        @diff        = Hash.new
      end

      def run
        read_global_rcfile
        read_environment_variable
        read_options
      end

      def read_environment_variable
        # process the environment variable
        if ENV["SVNDELTAOPTS"]
          options = ENV["SVNDELTAOPTS"].split(%r{\s+})
          while options.length > 0
            opt = options.shift
            log "processing opt " + opt
            arg = options.shift
            process_option(opt, options)
          end
        end
      end

      def read_options
        while ARGV.length > 0
          arg = ARGV.shift.dup
          if process_option(arg, ARGV)
            ARGV.unshift(arg)
            break
          end
        end
      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!(%r{^\-+}, "")

        case opt
          
        when "e", "execute"
          @execute = true

        when "n", "no-execute"
          @execute = false

        when "h", "help"
          show_help
          exit

        when "i", "confirm"
          @confirm = true
          
        when "q", "quiet"
          @quiet = true

        when "prune"
          @prune = true

        when "V", "verbose"
          @verbose = true

        when "r", "recurse"
          @recurse = true

        when "R", "no-recurse"
          @recurse = false

        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

        when "banner"
          @banner = true
        when %r{^no\-?banner$}
          @banner = false

        when "color"
          @color = true
          hl = Text::ANSIHighlighter
          @colors = {
            :banner  => hl.make("reverse bold"),
            :added   => hl.make("green on black"),
            :changed => hl.make("yellow on black"),
            :deleted => hl.make("red on black"),
          }
          
        when %r{^no\-?color$}
          @color = false

        when "config"
          printf "%s: %s\n", "banner", @banner
          printf "%s: %s\n", "color", @color
          printf "%s: %s\n", "confirm", @confirm
          printf "%s: %s\n", "execute", @execute
          printf "%s: %s\n", "package", @package
          printf "%s: %s\n", "prune", @prune
          printf "%s: %s\n", "quiet", @quiet
          printf "%s: %s\n", "recurse", @recurse
          printf "%s: %s\n", "verbose", @verbose
          printf "%s: %s\n", "version", @version
          printf "%s: %s\n", "ruby version", RUBY_VERSION
          exit
        else
          return true
        end
        
        return false
      end

      def read_rc_file(rc)
        IO.readlines(rc).each do |line|
          line.sub!(%r{\s*#.*}, "")
          line.chomp!
          name, value = line.split(%r{\s*[=:]\s*})
          next unless name && value

          case name
          when "adds"
            @adds = to_boolean(value)
          when "prune"
            @prune = to_boolean(value)
          when "color"
            @color = to_boolean(value)
          when "confirm"
            @confirm = to_boolean(value)
          when "execute"
            @execute = to_boolean(value)
          when "quiet"
            @quiet = to_boolean(value)
          when "verbose"
            @verbose = to_boolean(value)
          when "banner"
            @banner = to_boolean(value)
          end
        end
      end

      def read_global_rcfile
        # process the rc file
        if hd = Dir.home
          rc = hd + "/.svndeltarc"
          log "reading RC file: #{rc}"
          if File.exists?(rc)
            read_rc_file(rc)
          end
        end
      end

      def show_help

        puts "USAGE"
        puts "    svndelta [options] directory..."
        puts ""
        puts "OPTIONS"
        puts "    --banner, --no-banner"
        puts "        Whether to display the header and footer."
        puts ""
        puts "    -i, --confirm"
        puts "        Confirm each deleted file before removing it from Subversion."
        puts ""
        puts "    -e, --execute"
        puts "        Execute the associated Subversion commands (\"add\" and \"delete\")"
        puts "        for added and deleted files."
        puts ""
        puts "    -h, --help"
        puts "        Display a help message."
        puts ""
        puts "    -p, --prune"
        puts "        Skip directories that are not already in Subversion."
        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."

      end
    end
  
    class Executor
      include Loggable
      
      def initialize
        Log.set_widths(-15, 5, -40)
        
        # Log.verbose = true
        
        @options = Options.new($PACKAGE, $VERSION)
        @options.run

        Log.verbose = @options.verbose
        
        args = ARGV.dup
        if args.size == 0
          args = [ '.' ]
        end
        
        # we should be running this from a directory with an .svn subdirectory
        arg0 = Pathname.new(args[0])
        if arg0.exist?
          if arg0.file?
            arg0 = arg0.dirname
          end
          
          svndir = arg0 + '.svn'
          
          if !svndir.exist?
            $stderr.print "directory #{arg0} does not appear to be part of a Subversion project\n"
          end
        end
        
        @fds = Array.new
        @entries = Hash.new

        args.each do |arg|
          process_argument(arg)
        end

        get_delta

        print_delta

        if @options.execute
          execute
        end
      end

      # reads the entries in the given directory, adding only the entry for the
      # given file if it is not nil.

      def read_svn_entries(dir, file = nil)
        entfile = Pathname.new(dir) + '.svn/entries'
        
        if entfile.exist?
          log "reading entries file: #{entfile}"
          doc = REXML::Document.new(File.open(entfile))
          
          doc.elements[1].elements.each do |entry|
            name = entry.attributes['name']
            entname = dir.cleanpath
            if name != ''
              entname += name.to_s
            end
            attrs = {}
            entry.attributes.each { |attr, value| attrs[attr] = value }

            if !file || entname == file
              @entries[entname] = attrs
            end
          end
        else
          log "NO entries file: #{entfile}"
        end
      end
      
      def process_argument(arg)
        parg = Pathname.new(arg).cleanpath

        if parg.exist?
          if parg.directory?
            pdirs = Array.new
            parg.find do |p|

              log "p: #{p.inspect}"

              if p.file?
                log "file: #{p}"
                @fds << p
              elsif p.directory?
                log "directory: #{p}"

                if p.split[1].to_s == '.svn'
                  Find.prune
                else
                  svndir = p + '.svn'
                  log "checking for #{svndir}"
                  if svndir.exist?
                    info "adding directory"
                    @fds << p
                    
                    log "reading svn entries file"

                    # read the entries file:
                    read_svn_entries(svndir.dirname)
                  else
                    if @options.prune
                      log "pruning non-svn directory"
                      Find.prune
                    else
                      log "adding directory"
                      @fds << p
                    end
                  end
                end            
              end
            end
          elsif parg.file?
            @fds << parg
            read_svn_entries(parg.dirname, parg)
          end
        else
          $stderr.puts "#{parg.to_s}: No such file or directory"
        end
      end

      def get_size(x)
        p = Pathname.new(x)
        case 
        when p.exist? && p.file?
          FileTester.text?(p) ? p.readlines.size : "bin"
        else
          0
        end
      end

      def get_delta
        @delta = Hash.new
        
        (@entries.keys - @fds).each do |x|
          dir, name = Pathname.new(x).split
          oldfname = dir + '.svn/text-base/' + (x.to_s + '.svn-base')
          size = 0
          if oldfname.exist? && oldfname.file?
            size = FileTester.text?(oldfname) ? oldfname.readlines.size : "bin"
          end

          if @entries[x]['deleted'] != 'true'
            de = DeletedEntry.new
            de.executed = @entries[x]['schedule'] == 'delete'
            log "de.executed: #{de.executed}"
            de.ndeleted = size
            
            @delta[x] = de
          end
        end

        @fds.sort.each do |x|
          if SVN::IgnoreFiles.instance.match?(x)
            log "ignoring: #{x}"
            @fds.delete(x)
          else
            ent = @entries[x]

            ae = nil

            if ent
              if ent['schedule'] == 'add'
                ae = AddedEntry.new
                ae.executed = true
              end
            else
              ae = AddedEntry.new
              ae.executed = false
            end

            if ae
              ae.nadded = get_size(x)
              @delta[x] = ae
            end
          end
        end

        (@fds & @entries.keys).sort.each do |x|
          if x.file?
            dir, name = x.split
            oldfname = dir + '.svn/text-base/' + (name.to_s + '.svn-base')
            
            if FileTester.text?(x)
              if !oldfname.exist?
                # it's been added, but not committed, so it has no .svn cached file
                de = AddedEntry.new
                de.executed = true
                de.nadded = x.readlines.size

                @delta[x] = de
                next
              end

              fadded   = nil
              fchanged = nil
              fdeleted = nil
              
              if $USE_DIFF_LCS
                fadded, fchanged, fdeleted = get_lcs_diffs(oldfname, x)
              else
                fadded, fchanged, fdeleted = get_svn_diffs(oldfname, x)
              end

              if [ fadded, fchanged, fdeleted ].any? { |n| n > 0 }
                de = ChangedEntry.new
                
                de.nadded   = fadded
                de.nchanged = fchanged
                de.ndeleted = fdeleted
                @delta[x] = de
              end
            else
              log "comparing binary files"

              if oldfname.exist?
                oldsize = oldfname.size
                newsize = x.size
                diff    = (newsize - oldsize).abs
                log "diff: #{diff}"
                if diff > 0
                  de = ChangedEntry.new
                  de.executed = true
                  de.nadded   = 0
                  de.nchanged = diff
                  de.ndeleted = 0
                  @delta[x] = de
                end
              else
                # this means it's been added but not committed
                de = AddedEntry.new
                de.executed = true
                de.nadded   = x.size
                de.nchanged = 0
                de.ndeleted = 0

                @delta[x] = de
                next
              end
            end
          end
        end
      end

      def get_svn_diffs(svnname, filename)
        sd = Differ.new(filename)
        [ sd.added, sd.changed, sd.deleted ]
      end

      def get_lcs_diffs(a, b)
        diffs = Diff::LCS.diff(a.readlines, b.readlines)
        
        fadded = fchanged = fdeleted = 0
        
        diffs.each do |subdiffs|
          added = changed = deleted = 0

          # we count add/delete pairs as changed
          
          subdiffs.each do |diff|
            act = diff.action
            if diff.deleting?
              deleted += 1
            elsif diff.adding?
              added += 1
            elsif diff.changed?
              changed += 1
            end
          end
          
          if changed.zero? && added.nonzero? && deleted.nonzero?
            changed = [ added, deleted ].max
          end

          if changed.nonzero?
            fchanged += changed
          else
            if deleted.nonzero?
              fdeleted += deleted
            end
            if added.nonzero?
              fadded += added
            end
          end
        end

        [ fadded, fchanged, fdeleted ]
      end

      def print_delta
        if @options.banner
          puts
          headings = %w{ total added changed deleted file }
          spaces   = headings.collect { |x| 7 - x.length }
          if @options.color
            headings.collect! { |x| @options.colors[:banner].highlight(x) }
          end
          
          puts headings.collect_with_index { |x, i| x + (" " * spaces[i]) }.join('  ')
          puts headings.collect_with_index { |x, idx| '=' * (idx == headings.length - 1 ? 20 : 7) }.join('  ')
        end
        
        totals = Array.new(4, 0)

        @delta.sort.each do |name, what|
          add = chg = del = 0
          sym = nil

          case what
          when AddedEntry
            add = what.nadded
            sym = '+'
            if @options.color
              name = @options.colors[:added].highlight(name)
            end
          when DeletedEntry
            del = what.ndeleted
            sym = '-'
            if @options.color
              name = @options.colors[:deleted].highlight(name)
            end
          when ChangedEntry
            add = what.nadded
            chg = what.nchanged
            del = what.ndeleted
            sym = '*'
            if @options.color
              name = @options.colors[:changed].highlight(name)
            end
          end

          tot = if [ add, chg, del ].detect { |x| x == "bin" }
                  "bin" 
                else
                  add.to_i + chg.to_i + del.to_i
                end
          
          fields = [ tot, add, chg, del ].collect { |x| "%7s" % x }
          
          fields << sym + ' ' + name
          puts fields.join('  ')
          [ tot, add, chg, del ].each_with_index do |num, idx|
            # might be nil or String
            totals[idx] += num.to_i
          end
        end

        puts headings.collect_with_index { |x, idx| '-' * (idx == headings.length - 1 ? 20 : 7) }.join('  ')
        
        fields = totals.collect { |x| "%7d" % x }
        fields << "total"
        puts fields.join('  ')
      end

      def execute
        added   = extract_executables(AddedEntry)
        deleted = extract_executables(DeletedEntry)
        
        if @options.confirm
          deleted.reject! do |name|
            print "delete " + name + "? "
            ans = $stdin.readline
            ans.upcase[0, 1] != 'Y'
          end
        end
        
        added.sort!
        deleted.sort!

        log "added: #{added.inspect}"
        log "deleted: #{deleted.inspect}"
        
        if added.size > 0
          `svn add -N #{added.join(' ')}`
        end
        if deleted.size > 0
          `svn delete #{deleted.join(' ')}`
        end
      end

      def extract_executables(cls)
        @delta.keys.select do |name|
          entry = @delta[name]
          entry.class == cls && !entry.executed
        end
      end
    end
  end
end

if __FILE__ == $0
  SVN::Delta::Executor.new
end
