#!/bin/sh
exec ruby -w -x $0 ${1+"$@"} # -*- ruby -*-
#!ruby -w

# $Id: cvsdelta,v 1.58 2004/04/30 00:51:46 jeugenepace Exp $

# cvsdelta: summarizes CVS 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

#     Attribute codes:
#         00=none 01=bold 04=underscore 05=blink 07=reverse 08=concealed
#     Text color codes:
#         30=black 31=red 32=green 33=yellow 34=blue 35=magenta 36=cyan 37=white
#     Background color codes:
#         40=black 41=red 42=green 43=yellow 44=blue 45=magenta 46=cyan 47=white


#     Attribute codes:
#         00=none 01=bold 04=underscore 05=blink 07=reverse 08=concealed
#     Text color codes:
#         30=black 31=red 32=green 33=yellow 34=blue 35=magenta 36=cyan 37=white
#     Background color codes:
#         40=black 41=red 42=green 43=yellow 44=blue 45=magenta 46=cyan 47=white
module ANSIColor

  @@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',
  ]

  @@ATTRIBUTES.each do |name, val|
    eval <<-EODEF
      def ANSIColor.#{name}
        "\\e[#{val}m"
      end
    EODEF
  end

  def ANSIColor.attributes
    @@ATTRIBUTES.collect { |name, val| name }
  end
  
  # returns the code for the given color string, which is in the format:
  # [foreground] on [background]. 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 bold cyan

  def ANSIColor.code(str)
    fg, bg = str.split(/\s*\bon_?\s*/)
    (fg ? foreground(fg) : "") + (bg ? background(bg) : "")
  end

  # returns the code for the given background color(s)
  def ANSIColor.background(bgcolor)
    make_code("on_" + bgcolor)
  end

  # returns the code for the given foreground color(s)
  def ANSIColor.foreground(fgcolor)
    make_code(fgcolor)
  end

  protected

  def ANSIColor.make_code(str)
    if str
      str.split.collect do |s|
        if attr = @@ATTRIBUTES[s]
          "\e[#{attr}m"
        else
          $stderr.puts "WARNING: ANSIColor::make_code(" + str + "): unknown color: " + s
          return ""
        end
      end.join("")
    else
      ""
    end
  end

end


# Very minimal logging output. If verbose is set, this displays the method and
# line number whence called. It can be a mixin to a class, which displays the
# class and method from where it called. If not in a class, it displays only the
# method.

class Log

  $LOGGING_LEVEL = nil
  
  module Severity
    DEBUG = 0
    INFO  = 1
    WARN  = 2
    ERROR = 3
    FATAL = 4
  end

  include Log::Severity

  def initialize
    $LOGGING_LEVEL = @level = FATAL
    @ignored_files = {}
    @ignored_methods = {}
    @ignored_classes = {}
    @width = 0
    @output = $stderr
    @fmt = "[%s:%04d] {%s}"
    @autoalign = false
    @colors = []
    @colorize_line = false
  end
    
  def verbose=(v)
    @level = v ? DEBUG : FATAL
  end

  def level=(lvl)
    @level = lvl
  end

  # Assigns output to the given stream.
  def output=(io)
    @output = io
  end

  # sets whether to colorize the entire line, or just the message.
  def colorize_line=(col)
    @colorize_line = col
  end

  # Assigns output to a file with the given name. Returns the file; client
  # is responsible for closing it.
  def outfile=(f)
    @output = if f.kind_of?(IO) then f else File.new(f, "w") end
  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

  def ignore_file(fname)
    @ignored_files[fname] = true
  end
  
  def ignore_method(methname)
    @ignored_methods[methname] = true
  end
  
  def ignore_class(classname)
    @ignored_classes[classname] = true
  end

  def log_file(fname)
    @ignored_files.delete(fname)
  end
  
  def log_method(methname)
    @ignored_methods.delete(methname)
  end
  
  def log_class(classname)
    @ignored_classes.delete(classname)
  end

  # Sets auto-align of the {function} section.  set_widths will likely
  # result in nicer output.
  def autoalign
    @autoalign = true
  end

  def debug(msg = "", depth = 1, &blk)
    log(msg, DEBUG, depth + 1, &blk)
  end

  def info(msg = "", depth = 1, &blk)
    log(msg, INFO, depth + 1, &blk)
  end

  def warn(msg = "", depth = 1, &blk)
    log(msg, WARN, depth + 1, &blk)
  end

  def error(msg = "", depth = 1, &blk)
    log(msg, ERROR, depth + 1, &blk)
  end

  def fatal(msg = "", depth = 1, &blk)
    log(msg, FATAL, depth + 1, &blk)
  end

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

      if cname
        func = cname + "#" + func
      end

      if @ignored_files[file] || (cname && @ignored_classes[cname]) || @ignored_methods[func]
        # skip this one.
      elsif @autoalign
        print_autoaligned(file, line, func, msg, level, &blk)
      else
        print_formatted(file, line, func, msg, level, &blk)
      end
    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
        # puts "c    : #{c}"
        c.index(/(.*):(\d+)(?::in \`(.*)\')?/)
        file, line, func = $1, $2, $3
        file.sub!(/.*\//, "")

        func ||= "???"

        # puts "func : #{func}"
        # puts "cname: #{cname}"
        
        if cname
          func = cname + "#" + func
        end
        
        if @ignored_files[file] || (cname && @ignored_classes[cname]) || @ignored_methods[func]
        # skip this one.
        elsif @autoalign
          print_autoaligned(file, line, func, msg, level, &blk)
        else
          print_formatted(file, line, func, msg, level, &blk)
        end
        msg = ""
      end
    end
  end

  def print_autoaligned(file, line, func, msg, level, &blk)
    @width = [ @width, func.length ].max
    hdr = sprintf "[%s:%04d] {%-*s} ", file, line, @width
    print(hdr, msg, level)
  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)
    # puts "hdr: #{hdr}, msg #{msg}, level #{level}"
    if blk
      # puts "block is given"
      x = blk.call
      if x.kind_of?(String)
        # puts "got a string!"
        msg = x
      else
        # puts "did not get a string"
        return
      end
    else
      # puts "block not given"
    end

    if @colors[level]
      if @colorize_line
        @output.puts @colors[level] + hdr + " " + msg.to_s.chomp + ANSIColor.reset
      else
        @output.puts hdr + " " + @colors[level] + msg.to_s.chomp + ANSIColor.reset
      end
    else
      @output.puts hdr + " " + msg.to_s.chomp
    end      
  end

  def set_color(level, color)
    # log("#{level}, #{color}")
    @colors[level] = ANSIColor::code(color)
  end

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

  @@log = Log.new

  def Log.verbose=(v)
    @@log.verbose = v
  end

  def Log.level=(lvl)
    @@log.level = lvl
  end

  # Assigns output to the given stream.
  def Log.output=(io)
    @@log.output = io
  end

  # sets whether to colorize the entire line, or just the message.
  def Log.colorize_line=(col)
    @@log.colorize_line = col
  end

  # Assigns output to a file with the given name. Returns the file; client
  # is responsible for closing it.
  def Log.outfile=(fname)
    @@log.outfile = fname
  end

  # Creates a printf format for the given widths, for aligning output.
  def Log.set_widths(file_width, line_width, func_width)
    @@log.set_widths(file_width, line_width, func_width)
  end

  def Log.ignore_file(fname)
    @@log.ignore_file(fname)
  end
  
  def Log.ignore_method(methname)
    @@ignored_methods[methname] = true
  end
  
  def Log.ignore_class(classname)
    @@ignored_classes[classname] = true
  end

  def Log.log_file(fname)
    @@log.log_file(fname)
  end
  
  def Log.log_method(methname)
    @@log.log_method(methname)
  end
  
  def Log.log_class(classname)
    @@log.log_class(classname)
  end

  # Sets auto-align of the {function} section.  set_widths will likely
  # result in nicer output.
  def Log.autoalign
    @@log.autoalign
  end

  def Log.debug(msg = "", depth = 1, &blk)
    @@log.log(msg, DEBUG, depth + 1, &blk)
  end

  def Log.info(msg = "", depth = 1, &blk)
    @@log.log(msg, INFO, depth + 1, &blk)
  end

  def Log.warn(msg = "", depth = 1, &blk)
    @@log.log(msg, WARN, depth + 1, &blk)
  end

  def Log.error(msg = "", depth = 1, &blk)
    @@log.log(msg, ERROR, depth + 1, &blk)
  end

  def Log.fatal(msg = "", depth = 1, &blk)
    @@log.log(msg, FATAL, depth + 1, &blk)
  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.set_color(level, color)
    @@log.set_color(level, color)
  end

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

end


class AppLog < Log
  include Log::Severity

end


module Loggable

  # Logs the given message, including the class whence invoked.
  def log(msg = "", level = Log::DEBUG, depth = 1, &blk)
    AppLog.log(msg, level, depth + 1, self.class.to_s)
  end

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

  def info(msg = "", depth = 1, &blk)
    AppLog.log(msg, Log::INFO, depth + 1, self.class.to_s, &blk)
  end

  def warn(msg = "", depth = 1, &blk)
    AppLog.log(msg, Log::WARN, depth + 1, self.class.to_s, &blk)
  end

  def error(msg = "", depth = 1, &blk)
    AppLog.log(msg, Log::ERROR, depth + 1, self.class.to_s, &blk)
  end

  def fatal(msg = "", depth = 1, &blk)
    AppLog.log(msg, Log::FATAL, depth + 1, self.class.to_s, &blk)
  end

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

end




class CVSDeltaOptions
  include Loggable

  attr_accessor :adds
  attr_accessor :banner
  attr_accessor :changes
  attr_accessor :compression
  attr_accessor :confirm
  attr_accessor :deletes
  attr_accessor :execute
  attr_accessor :fromdate
  attr_accessor :fromrev
  attr_accessor :package
  attr_accessor :process_unknown_dirs
  attr_accessor :progress
  attr_accessor :quiet
  attr_accessor :tmpdir
  attr_accessor :todate
  attr_accessor :torev
  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
    @compression = 3            # compression for net transfer

    @changes     = true         # whether to show files that changed
    @adds        = true         # whether to show files that were added
    @deletes     = true         # whether to show files that were deleted
    @execute     = false        # whether to execute

    @banner      = true
    @progress    = false

    @fromdate    = nil          # starting date of comparison ("cvs -D @fromrev")
    @todate      = nil          # ending date of comparison ("cvs -. @from... -D @todate")
    @fromrev     = nil          # starting revision of comparison ("cvs -r @fromrev")
    @torev       = nil          # ending revision of comparison ("cvs -... @from.... -r @torev")
    @process_unknown_dirs = true    # whether to skip directories that are not in CVS
    @tmpdir      = ENV["TMPDIR"] || "/tmp"
  end

  def run
    read_global_rcfile
    read_local_rcfile
    read_environment_variable
    read_options
  end

  def read_environment_variable
    # process the environment variable
    if ENV["CVSDELTAOPTS"]
      options = ENV["CVSDELTAOPTS"].split(/\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
      # can't modify ARGV, as of Ruby 1.8.1:
      arg = ARGV.shift.dup
      log "processing #{arg}"
      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!(/^\-+/, "")

    case opt
      
    when "a", "adds"
      @adds = true
    when "A", "no-adds"
      @adds = false

    when "c", "changes"
      @changes = true
    when "C", "no-changes", "nodiff"
      @changes = false
      @changes = false

    when "d", "deletes"
      @deletes = true
    when "D", "no-deletes"
      @deletes = false

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

    when "h", "help"
      show_help
      exit

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

    when "s", "skip-unknown-directories"
      @process_unknown_dirs = false

    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

    when "z", "compression"
      # no need to convert it from an integer, since it'll be written back out as
      # a string:
      @compression = args.shift

    when "f", "from-date"
      @fromdate = args.shift

    when "t", "to-date"
      @todate = args.shift

    when "F", "from-revision"
      @fromrev = args.shift
      
    when "T", "to-revision"
      @torev = args.shift

    when "banner"
      @banner = true
    when /^no\-?banner$/
      @banner = false

    when "progress"
      @progress = true
    when /^no\-?progress$/
      @progress = false

    when "config"
      printf "%s: %s\n", "adds", @adds
      printf "%s: %s\n", "banner", @banner
      printf "%s: %s\n", "changes", @changes
      printf "%s: %s\n", "compression", @compression
      printf "%s: %s\n", "confirm", @confirm
      printf "%s: %s\n", "deletes", @deletes
      printf "%s: %s\n", "execute", @execute
      printf "%s: %s\n", "fromdate", @fromdate
      printf "%s: %s\n", "fromrev", @fromrev
      printf "%s: %s\n", "package", @package
      printf "%s: %s\n", "process_unknown_dirs", @process_unknown_dirs
      printf "%s: %s\n", "progress", @progress
      printf "%s: %s\n", "quiet", @quiet
      printf "%s: %s\n", "tmpdir", @tmpdir
      printf "%s: %s\n", "todate", @todate
      printf "%s: %s\n", "torev", @torev
      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!(/\s*#.*/, "")
      line.chomp!
      name, value = line.split(/\s*[=:]\s*/)
      next unless name && value

      case name
      when "adds"
        @adds = to_boolean(value)
      when "skip-unknown-directories"
        @process_unknown_dirs = !to_boolean(value)
      when "changes"
        @changes = to_boolean(value)
      when "compression"
        # no need to convert it from an integer, since it'll be written back out
        # as a string:
        @compression = value
      when "confirm"
        @confirm = to_boolean(value)
      when "deletes"
        @deletes = to_boolean(value)
      when "diff"
        @changes = !to_boolean(value)
      when "execute"
        @execute = to_boolean(value)
      when "quiet"
        @quiet = @progress = 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 = home_directory
      rc = hd + "/.cvsdeltarc"
      log "reading RC file: #{rc}"
      if File.exists?(rc)
        read_rc_file(rc)
      end
    end
  end

  def read_local_rcfile
    # Use the topmost resource file in this project. We may refine this
    # functionality so that multiple rc files can be used within a project

    topdir = find_top_of_project

    if topdir && File.exists?(topdir + "/.cvsdeltarc")
      read_rc_file(topdir + "/.cvsdeltarc")
    end
  end

  def find_top_of_project(dir = File.expand_path("."))
    repfile = dir + "/CVS/Repository"
    if File.exists?(repfile)
      IO.readlines(repfile).each do |line|
        if line.index("/")
          # keep going up the directory structure
          if dir == "/"
            return nil
          else
            return find_top_of_project(File.dirname(dir))
          end
        else
          return dir
        end
      end
    end
    return nil
  end

  def show_help

    puts "USAGE"
    puts "    cvsdelta [options] directory..."
    puts ""
    puts "OPTIONS"
    puts "    -a, --adds"
    puts "        Display the files that were added."
    puts ""
    puts "    -A, --no-adds"
    puts "        Do not display the files that were added."
    puts ""
    puts "    --banner, --no-banner"
    puts "        Whether to display the header and footer."
    puts ""
    puts "    -c, --changes"
    puts "        Display the files that were changed."
    puts ""
    puts "    -C, --no-changes, --nodiff"
    puts "        Do not compare files that exist locally and in CVS."
    puts ""
    puts "    -d, --deletes"
    puts "        Display the files that were deleted."
    puts ""
    puts "    -D, --no-deletes"
    puts "        Do not display the files that were deleted."
    puts ""
    puts "    -e, --execute"
    puts "        Execute the associated CVS commands (\"add\" and \"remove\") for "
    puts "        the added and deleted files."
    puts ""
    puts "    -f, --from-date"
    puts "        Compare the files to their version as of the given date, rather"
    puts "        than their current version in CVS."
    puts ""
    puts "    -F, --from-revision"
    puts "        Compare the files to their version as of the given revision, rather"
    puts "        than their current version in CVS."
    puts ""
    puts "    -h, --help"
    puts "        Display a help message."
    puts ""
    puts "    -i, --confirm"
    puts "        Interactively confirm deleted files with the user before removing them"
    puts "        from CVS."
    puts ""
    puts "    --progress, --no-progress"
    puts "        Whether to show the progress meter."
    puts ""
    puts "    -q, --quiet"
    puts "        Run with minimum output."
    puts ""
    puts "    -s, --skip-unknown-directories"
    puts "        Skip directories that are not in CVS."
    puts ""
    puts "    -t, --to-date"
    puts "        Compare the files to their version as of the given date, rather"
    puts "        than to the local files. This is valid only with the --from-date"
    puts "        or --from-revision options."
    puts ""
    puts "    -T, --to-revision"
    puts "        Compare the files to their version as of the given revision, rather"
    puts "        than to the local files. This is valid only with the --from-date"
    puts "        or --from-revision options."
    puts ""
    puts "    -v, --version"
    puts "        Display the version and exit."
    puts ""
    puts "    -V, --verbose"
    puts "        Run with maximum output."
    puts ""
    puts "    -z [LEVEL], ---compression [LEVEL]"
    puts "        Set the compression to the given level for net traffic."

  end

end



# cvsdelta: summarizes CVS changes and executes the appropriate commands

require 'find'


# 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


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

  # 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)
    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




# 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


# Represents .cvsignore files.

class IgnoredPatterns < Hash
  include Loggable

  def initialize
    @ignorename = ".cvsignore"
    @dirsread = Array.new
  end

  def read(dir)
    # from the CVS default settings -- ignoring overrides

    log dir

    return if @dirsread.include?(dir)
    @dirsread.push(dir)

    log "reading #{dir}"

    pats = %w{
                  CVS
                  *~
                  .cvsignore
                  *.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 the repository-wide cvsignore file, if it exists and is local.
    #cvsroot = ENV["CVSROOT"]
    #if cvsroot
    #  cri = cvsroot + "/CVSROOT/cvsignore"
    #  repo = read_ignore_file_named(cri)
    #  pats.push(*repo) unless repo.length == 0
    #end

    # 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

    # make a regular expression for each one, to be the entire string (^...$)

    qdir = Regexp.quote(dir)
    self[dir] = pats.collect do |p| 
      p = qdir + "/" + Regexp.unixre_to_string(p)
      re = Regexp.new("^" + p + "$")
      log "storing ignored pattern re " + re.source + ", dir " + dir
      re
    end
  end

    # Reads the ignore file from the given directory, using the default ignore
    # name.

  def read_ignore_file(dir)
    pats = Array.new

    if dir
      cifile = dir + "/" + @ignorename
      if File.exists?(cifile)
        log "reading #{cifile}"
        IO.foreach(cifile) do |line|
          line.chomp!
          line.gsub!(/\+/, '\\+')
          pats.push(*line.split) if line.split.size > 0
        end
      else
        log "no ignore file in " + dir
      end
    end

    log "patterns: #{pats}"

    pats
  end

    # Reads the given ignore file, if it exists.

  def read_ignore_file_named(fname)
    pats = Array.new

    if File.exists?(fname)
      IO.foreach(fname) do |line|
        line.chomp!
        line.gsub!(/\+/, '\\+')
        pats.push(line)
      end
    else
      log "no such file: " + fname
    end

    pats
  end

  # Returns if the file is ignored. Checks the name as both "./name" and "name".

  def is_ignored?(name)
    # log "name = #{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
  include Loggable

  attr_accessor :adds, :changes, :deletes, :name

  UNKNOWN = -1
  BINARY = -2
  
  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

  def is_counted?
    [ @adds, @changes, @deletes ].each do |v|
      if [ UNKNOWN, BINARY ].grep(v).length > 0
        return false
      end
    end
    return true
  end

  def print(nm = nil)
    nm = symbol + " " + name unless nm
    [total, adds, changes, deletes].each do |v|
      str = 
        case v
        when UNKNOWN
          "???"
        when BINARY
          "bin"
        else
          v.to_s
        end
      printf("%7s  ", str)
    end
    $stdout.print nm, "\n"
  end

  def to_s
    self.class.to_s + " \"#{@name}\" a:#{@adds} c:#{@changes} d:#{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
    @deletes = UNKNOWN
  end

  def symbol; "-"; end
  
end


class NewFile < DeltaFile

  def NewFile.create(fname)
    File.is_text?(fname) ? NewFile.new(fname) : NewBinaryFile.new(fname)
  end

  def initialize(name)
    super
    @adds = IO.readlines(name).length
  end

  def symbol; "+"; end
  
end


class NewBinaryFile < DeltaFile

  def initialize(name)
    super
    f = File.new(name)
    # @adds = f.read.length
    # @adds = f.readlines.length
    @adds = BINARY
  end

  def symbol; "+"; end
  
#   def print(nm = nil)
#     nm = symbol + " " + name unless nm
#     [total, adds, changes, deletes].each do |v|
#       printf("%7sb ", v == @@UNKNOWN ? "???" : v.to_s)
#     end
#     $stdout.print nm, "\n"
#   end

end


class AddedUncountedFile < DeltaFile

  def initialize(name)
    super
    @adds = UNKNOWN
  end

  def symbol; "+"; end
  
end

# Processes output from "cvs diff".


class DiffOutput
  include Loggable

  def initialize(total)
    fmt = '(\d+)(?:,(\d+))?'
    @total  = total
    @regexp = Regexp.new("^" + fmt + char + fmt)
    @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

  # 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 DiffOutputAdd < DiffOutput

  def char; 'a'; end

  def update_record(rec, nlines)
    rec.adds += nlines
  end

end


class DiffOutputChange < DiffOutput

  def char; 'c'; end

  def update_record(rec, nlines)
    rec.changes += nlines
  end

end


class DiffOutputDelete < DiffOutput

  def char; 'd'; end

  def update_record(rec, nlines)
    rec.deletes += nlines
  end

end


class CVSDiff
  include Loggable

  attr_accessor :tmpdir, :fromdate, :fromrev, :todate, :torev, :compression, :progress, :total

  attr_reader :missing, :deleted, :changed

  def initialize(args, verbose = nil)
    name = self.class

    @total = DeltaFile.new("total")

    @addre = DiffOutputAdd.new(@total)
    @delre = DiffOutputDelete.new(@total)
    @chgre = DiffOutputChange.new(@total)

    @tmpdir = "/tmp"
    @fromdate = nil
    @fromrev = nil
    @todate = nil
    @torev = nil
    @compression = 3
    @progress = nil
    @verbose = verbose
    @args = args
  end

  def run
    # Ignore the .cvsrc file; handle only normal diff output.
    
    # Tweaking compression (via -z[0 .. 9]) makes diff less likely to hang
    # after producing output. Both -z0 and -z9 work best on my system (against
    # the doctorj CVS repository at SourceForge.net).

    curfile = nil

    diffopts  = ""
    if @fromrev || @fromdate
      if @fromrev
        diffopts += " -r #{@fromrev} "
      else
        diffopts += " -D \"#{@fromdate}\" "
      end
      if @torev || @todate
        if @torev
          diffopts += " -r #{@torev} "
        else
          diffopts += " -D \"#{@todate}\" "
        end
      end
    elsif @torev || @todate
      $stderr.puts "ERROR: --to-... option requires --from-..."
      exit
    end
    
    diffopts += @args.join(" ")

    @missing = FileArray.new
    @deleted = FileArray.new
    @changed = FileHash.new

    # backticks seem to work more consistenty than IO.popen, which was losing
    # lines from the CVS diff output.

    cmd = "cvs -fq -z" + @compression.to_s + " diff " + diffopts + " 2>&1"
    log "executing command " + cmd

    # For whatever reason, between CVS and Ruby, the best combination is to
    # write CVS's output to a temporary file. Trying to tie to the output
    # streams was resulting in some output being lost, especially that written
    # to stderr.

    pid = Process.pid
    outfile = @tmpdir + "/cvsdelta." + pid.to_s

    trap("INT") do 
      # If we get interrupted, make sure we delete the outfile:
      File.unlink(outfile) if File.exists?(outfile)

      # This bypasses the stack trace on exit.
      abort
    end

    cmd = "(" + cmd + ") > " + outfile
    `#{cmd}`

    lines = IO.readlines(outfile)
    if lines.size > 0 && lines[0].index(/cvs \[diff aborted\]: Can\'t parse date\/time: (.+)/)
      $stderr.puts "ERROR: invalid date/time: '#{$1}'"
      exit
    end
    
    lines.each do |line|
      @progress.tick(line) if @progress

      log "line: " + line

      if line.index(/^\?\s*(\S*)/)
        # some CVS servers seem to write new files as "? foo/bar.x", but we'll
        # figure out the new files for ourselves anyway
      elsif line.index(/^cvs server:\s*(\S*)was removed/) ||
          line.index(/^cvs (?:diff|server): *cannot find\s*(\S*)/)
        # various ways that CVS servers tell us what was removed, but we'll
        # figure it out for ourself
        file = $1
        # add_deleted_file(file)
        log "deleted file: " + file
      elsif line.index(/^Index:\s+(\S+)/)
        curfile = $1
        log "new current file: #{curfile}"
      elsif line.index(/^\s*cvs (?:diff|server): no revision for .+ in file (.*)/)
        fname = $1
        log "missing file line: #{line}"
        log "adding missing file: #{fname}"
        @missing.push(fname)
      elsif line.index(/^\s*cvs server: tag .*? is not in file (.*)/)
        fname = $1
        log "missing file line: #{line}"
        log "adding missing file: #{fname}"
        @missing.push(fname)
      elsif line.index(/^\s*cvs server: (.*) no longer exists/)
        fname = $1
        log "deleted file #{fname} from line: #{line}"
        @deleted.push(fname)
      elsif line.index(/^Binary files .*? and .*? differ/)
        log "binary files differ"
        rec = get_record(curfile)
        rec.changes = DeltaFile::BINARY
      else
        [ @addre, @chgre, @delre ].each do |re|
          if re.match(line)
            rec = get_record(curfile)
            re.update(rec)
            break
          else
            # log re.to_s + ": not a match line: " + line.chomp
          end
        end
      end
    end

    log "unlinking #{outfile}"

    File.unlink(outfile)
  end

  def dump
    puts "missing:"
    @missing.each do |f|
      puts "    #{f}"
    end
    
    puts "deleted:"
    @deleted.each do |f|
      puts "    #{f}"
    end

    puts "changed:"
    @changed.each do |fname, record|
      puts "    #{record}"
    end
  end

  def get_record(file)
    unless @changed.include?(file)
      @changed[file] = ExistingFile.new(file)
    end
    @changed[file]
  end

end





# CVS requires that added directories and files be done in their own set. That
# is, it is invalid to give CVS the command "cvs add foo foo/bar.txt". That must
# be done as two separate commands.

class CVSEntry
  include Loggable
  
  attr_accessor :added, :parent, :name, :setnumber, :incvs

  @@entries = Hash.new

  def CVSEntry.entries
    @@entries
  end
  
  def initialize(name)
    @name = name
    @incvs = File.exists?(name + "/CVS/Entries")
    @added = false

    @@entries[name] = self
    
    @parent = nil
    if (fd = File.dirname(name)) && fd != "."
      if @@entries.has_key?(fd)
        @parent = @@entries[fd]
      else
        @parent = CVSEntry.new(fd)
      end
    end
  end

  def to_s
    @name
  end

  # Adds to the given list, if it can. Returns whether successful.
  def add(addset)
    log "(#{@name}, #{addset})"
    if @parent
      if @parent.incvs
        @setnumber = 0
      else
        unless @parent.added
          log "adding parent"
          @parent.add(addset)
        end
        @setnumber = @parent.setnumber + 1
      end
    else
      log "adding orphan to set 0"
      @setnumber = 0
    end
    addset.add(self, @setnumber)
    @added = true
  end
  
end

class CVSAddSet
  include Loggable

  attr_reader :addlists

  def initialize(files)
    entries = files.collect { |f| CVSEntry.new(f) }

    @addlists = Array.new
    setnum = 0
    while entries.size > 0
      curlist = Array.new
      entries.each do |e|
        if e.add(self)
          entries.delete(e)
        end
      end
      setnum += 1      
    end
  end

  def add(entry, setnum)
    log "(#{entry}, #{setnum})"
    unless al = @addlists[setnum]
      al = @addlists[setnum] = Array.new
    end
    al.push(entry) unless al.include?(entry)
  end

end



# Executes CVS commands.

class CVSExecute
  include Loggable

  attr_accessor :confirm, :diagnose
  
  def initialize(added, deleted)
    @added    = if added.kind_of?(Hash)   then added.keys   else added   end
    @deleted  = if deleted.kind_of?(Hash) then deleted.keys else deleted end
    @confirm  = false
    @diagnose = false
  end
  
  def run
    print "\nEXECUTING COMMANDS\n"
    
    print "\n    ADDs\n"

    if @added.length > 0
      addset = CVSAddSet.new(@added)
      addset.addlists.each do |list|
        names = list.collect { |entry| entry.name }
        execute_command(names, "add")
      end
    end

    if @confirm
      dels = @deleted.reject do |name|
        print "delete " + name + "? "
        ans = $stdin.readline
        ans.upcase[0, 1] != 'Y'
      end
    else
      dels = @deleted
    end

    print "\n    DELETEs\n"
    execute_command(dels, "remove")
  end

  def execute_command(names, command)
    if names.size > 0
      # Quote file names, so that spaces are essentially escaped.
      # TODO: Handle file names that have doublequotes in them.
      cmd = "cvs " + command + ' ' + names.collect { |n| '"' + n + '"' }.join(" ")
      print "        ", cmd, "\n"
      unless diagnose
        system(cmd)
      end
    else
      log "no files to " + command
    end
  end

end



class String

  # returns if the given string is ASCII.
  def is_ascii?
    each do |ch|
      # from ctype.h
      if (ch.to_i & ~0x7f) != 0
        return false
      end
    end
    return true
  end

end


# A difference within a configuration management system.

class CVSDelta
  include Loggable
  
  attr_reader :added, :changed, :deleted, :total
  
  def initialize(options, args)
    @options = options

    # for showing that we're actually doing something
    @progress = if @options.progress then ProgressMeter.new(@options.verbose) else nil end

    @ignored_patterns = IgnoredPatterns.new

    log "args: #{args}"
    
    args = [ "." ] unless args.length > 0

    @unreadable = Hash.new
    @entries  = Array.new
    @entfiles = Array.new

    @args = args
  end

  def run
    if @options.changes
      read_changes(@args)
    else
      log "not processing changes"
      @added    = FileHash.new
      @changed  = FileHash.new
      @deleted  = FileHash.new
      @total    = DeltaFile.new("total")
    end

    # determine new files

    if @options.adds || @options.deletes
      read_ignored_directories(@args)
      
      read_added_deleted_files(@args)
    else
      log "not processing adds or deletes"
    end
  end

  def read_ignored_directories(dirs)
    dirs = @args.collect { |a| File.dirname(a) }.uniq
    dirs.each do |dir| 
      @ignored_patterns.read(dir)
    end
  end

  def read_ignored_directory(dirs)
    dirs = @args.collect { |a| File.dirname(a) }.uniq
    # dirs << cvsed_parent_directories(dirs)
    # dirs = @args.collect { |a| File.dirname(a) }.uniq
    # dirs << cvsed_parent_directories(dirs)

    dirs.each do |dir| 
      @ignored_patterns.read(dir)
    end

    exit
  end

  def read_changes(args)
    diff = CVSDiff.new(args)

    diff.fromdate = @options.fromdate
    diff.fromrev = @options.fromrev
    diff.todate = @options.todate
    diff.torev = @options.torev
    diff.tmpdir = @options.tmpdir

    diff.run

    @added    = FileHash.new
    @changed  = diff.changed
    @deleted  = FileHash.new
    @total    = diff.total

    # If it is only from, we can just get the line count from the local files.
    # If there is a to, we have to use that date as the update time.
    # And we have to deal with binary files.

    # Unfortunately, cvs spews out "update -p" without anything to indicate the
    # start or end of files. So we can get the total line count correctly, but
    # not for the individual files. Thus, they'll just get "???" for their line
    # counts, but the total will be right. And that's probably what most people
    # are interested in, so that's OK.

    if diff.missing.size > 0
      if @options.torev || @options.todate
        cmd = "cvs -fq -z" + @options.compression.to_s + " update -p "
        if @options.torev
          cmd += " -r #{@options.torev} "
        else
          cmd += " -D \"#{@options.todate}\" "
        end
        diff.missing.each do |mf|
          cmd +=  " \"#{mf}\""
        end

        log "running #{cmd}"

        lines = `#{cmd}`
        begin
          linecount = lines.select { |line| line.is_ascii? }.size
        rescue
          # this might have been a file that had not been properly
          # registered as a binary file.
          linecount = 1
        end
        diff.missing.each do |mf|
          fname = File.clean_name(mf)
          log "#{mf} => #{fname}"
          @added[fname] = AddedUncountedFile.new(fname)
        end

        log "incrementing total adds by #{linecount}"
        diff.total.adds += linecount
      else
        diff.missing.each do |mf|
          fname = File.clean_name(mf)
          log "#{mf} => #{fname}"
          add_file(fname)
        end
      end
    end

    if diff.deleted.size > 0
      diff.deleted.each do |mf|
        fname = File.clean_name(mf)
        log "#{mf} => #{fname}"
        @deleted[fname] = DeletedFile.new(fname)
      end
    end

  end

  def read_added_deleted_files(args)
    log "cvsdelta: read_added_deleted_files(" + args.join(", ") + ")"
    
    args.each do |arg|
      if File.directory?(arg)
        if @options.process_unknown_dirs
          read_each_subdirectory(arg)
        else
          read_each_cvsed_subdirectory(arg)
        end
      else
        # read the CVS/Entries file in the directory of the file
        dir = File.dirname(arg)
        read_entries_file(dir) unless dir.index(/\bCVS\b/)
        log "adding file #{arg}"
        consider_file_for_addition(arg)
      end
    end
  end

  def read_each_subdirectory(top)
    # log top

    File.find_directories(top).each do |dir|
      log "processing dir #{dir}"
      unless dir.index(/\bCVS\b/)
        read_entries_file(dir) 
      end
    end

    File.find_files(top).each do |f|
      # log "adding found file: #{f}"
      consider_file_for_addition(f)
    end
  end

  def read_each_cvsed_subdirectory(top)
    log top
    
    cvsdirs = File.find_where(top) do |fd| 
      File.is_directory?(fd) && File.exists?(fd + "/CVS/Entries")
    end
    
    cvsdirs.each do |dir|
      log "processing dir #{dir}"
      read_entries_file(dir) 
      
      File.local_files(dir).each do |f|
        log "adding local file: #{f}"
        consider_file_for_addition(f)
      end
    end
  end

  def read_entries_file(dir)
    entfile = dir + "/CVS/Entries"
    if @entfiles.include?(entfile)
      log "entries file " + entfile + " already read"
    elsif !File.exists?(entfile)
      log "no entries file: " + entfile
    else
      log "reading entries file: " + entfile
      IO.foreach(entfile) do |line|
        @progress.tick(dir) if @progress
        file, ver, date = line.split('/')[1 .. 3]
        log "file: #{file}; ver: #{ver}; date: #{date}"
        if file and ver and !file.empty? and !ver.empty?
          fullname = File.clean_name(dir + "/" + file)
          log "adding entry: " + fullname
          @entries.push(fullname)
          
          if date == "dummy timestamp" || date.index("Initial added")
            if File.exists?(fullname)
              log "adding new file as changed"
              @changed[fullname] = NewFile.create(fullname)
              @total.adds += @changed[fullname].adds
            else
              log "adding removed file as changed"
              @changed[fullname] = DeletedFile.new(fullname)
            end
          elsif not File.exists?(fullname)
            log "entry " + fullname + " is missing"
            add_deleted_file(fullname)
          end
        end
      end
      @entfiles.push(entfile)
      @ignored_patterns.read(dir)
    end
  end

  def add_file(fname)
    log "file name = #{fname}"
    
    if File.readable?(fname)
      unless @added.include?(fname)
        # don't add it twice
        @added[fname] = NewFile.create(fname)
        if @added[fname].is_counted?
          log "adding #{fname}: #adds: #{@added[fname].adds}"
          @total.adds += @added[fname].adds
        else
          log "adding #{fname}: not counted"
        end
      end
    elsif !@unreadable.has_key?(fname)
      puts "\rnot readable: " + fname 
      @unreadable[fname] = true
    end
  end

  def file_included?(fname)
    @entries.include?(fname)
  end

  def file_ignored?(fname)
    @ignored_patterns.is_ignored?(fname)
  end

  def consider_file_for_addition(file)
    if @options.adds
      fname = File.clean_name(file)
      unless file_included?(fname) || file_ignored?(fname)
        add_file(fname)
      end
    else
      log "not adding new file " + file
    end
  end

  def add_deleted_file(file)
    if @options.deletes
      @deleted[file] = DeletedFile.new(file)
    else
      log "not adding deleted file " + file
    end
  end
  
  def print_change_summary
    if @options.banner
      puts
      printf "%-7s  %-7s  %-7s  %-7s  %s\n", "total", "added", "changed", "deleted", "file"
      printf "=======  =======  =======  =======  ====================\n"
    end

    files = Hash.new
    [ added, changed, deleted ].each do |ary|
      ary.each do |file, record| 
        files[file] = record
      end
    end

    files.sort.each { |file, record| record.print }
    if @options.banner
      printf "-------  -------  -------  -------  --------------------\n";
    end
    total.print("Total")
  end
  
end


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

$PACKAGE = "cvsdelta"
$VERSION = "1.7.0"

begin
  Log.set_widths(15, -5, -35)

  # Log.verbose = true

  options = CVSDeltaOptions.new($PACKAGE, $VERSION)
  options.run

  Log.verbose = options.verbose
  # Log.output = "/tmp/cvsdelta.log." + Process.pid.to_s

  # we should be running this from a CVS'ed directory
  unless File.exists?("CVS")
    $stderr.print "this directory does not appear to be part of a CVS project\n"
  end

  delta = CVSDelta.new(options, ARGV)
  delta.run
  delta.print_change_summary

  if options.execute
    exec = CVSExecute.new(delta.added, delta.deleted)
    exec.confirm = options.confirm
    exec.run
  end
rescue => e
  # show only the message, not the stack trace:
  $stderr.puts "error: #{e}"
  # puts e.backtrace
end
