#! /usr/bin/ruby

require 'etc'
require 'fileutils'
require 'time'
require 'optparse'
require 'optparse/time'

class Dir
  TMPDIR = ENV['TMPDIR'] || ENV['TMP'] || ENV['TEMP'] || '/tmp'
  def self.tmpdir(n = $0)
    d = File.join(TMPDIR, "%s-%d-%.6x" % [File.basename(n), $$, rand(0x1000000)])
    mkdir(d)
    d
  rescue
    retry
  end
end

$fullname = Object.new
def $fullname.to_s
  $fullname = Etc.getpwnam(Etc.getlogin).gecos
end

$mailaddr = Object.new
def $mailaddr.to_s
  open(File.expand_path("~/.netrc")) do |f|
    f.grep(/^default\s+login\s+anonymous\s+password\s(\S+)/) do
      return $mailaddr = $1
    end
  end
  $mailaddr = ""
end

class CVS
  ChangeLog = "ChangeLog"

  attr_reader :module
  attr_accessor :root, :workdir, :revision, :noharm

  def initialize(mod, root, revision = nil)
    @module = mod || File.open("CVS/Repository"){|f|f.gets.chomp}
    @root = root || File.open("CVS/Root"){|f|f.gets.chomp}
    @comargs = []
    @revision = revision
    @workdir = nil
  end

  def setup_argv(args)
    args.unshift("-d", @root) if @root
    args.unshift("-n") if @noharm
    args
  end

  def invoke(*args)
    args = setup_argv(args)
    Process.waitpid(fork {setdir; yield if block_given?; exec("cvs", *args)})
    $? == 0
  end

  def prefix(files)
    mod = @module
    n = nil
    files.map {|n| "#{mod}/#{n}"}
  end

  def checkout(*files, &block)
    files[0, 0] = ["-r", @revision] if @revision
    invoke("co", *files, &block) or raise
  end

  def checkin(log, *files, &block)
    if files.empty?
      files = [@module]
    end
    invoke("ci", "-m", log, *files, &block) or raise
  end

  def add(*files, &block)
    files.each do |file|
      file = file[(@module.size+1)..-1] unless @module.empty?
      cvsdir = File.dirname(file)+"/CVS"
      next if File.directory?(cvsdir)
      puts "making directory #{cvsdir}"
      FileUtils.makedirs(cvsdir)
      create = IO::CREAT|IO::EXCL|IO::WRONLY
      [["Repository", @repository], ["Root", @root], ["Entries", nil]].each do
        |file, line|
        begin
          open(File.join(cvsdir, file), create) {|f| f.puts(line) if line}
        rescue Errno::EEXIST
        end
      end
    end
    invoke("add", *files, &block) or raise
  end

  def read(*args)
    args = setup_argv(args)
    IO.popen("-") do |f|
      setdir unless f
      yield f if block_given?
      exec("cvs", *args) unless f
    end
  end

  def diff(*files)
    read("diff", "-N", *files) do |f|
      if f
        if block_given?
          yield(f)
        else
          f
        end
      else
        Dir.chdir(@module)
      end
    end
  end

  def setdir
    Dir.chdir(@workdir) if @workdir
  end

  def logfile
    File.join(@module, self.class::ChangeLog)
  end
end

class Patch
  CurrentTime = "%a %b %-2d %X %Y"
  Entry = {
    /\A(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat|\d+)\s.*/ => CurrentTime,
    /\A\d{4}-\d{1,2}-\d{1,2}\s/ => "%Y-%2m-%2d",
  }
  Separator = "\n\f\n"

  attr_reader :patch, :files, :newfiles

  def readpage(file)
    data = file.gets(Separator) and data.sub!(/\f\n\z/, '')
    data
  end

  def make_entry(time, style)
    [time.strftime(Entry.fetch(style) {CurrentTime}), $fullname, "<#$mailaddr>"].join("  ")
  end

  def initialize(file, prefix = nil, strip = nil)
    @logs = []
    @patch = []
    entries = Entry.keys
    start_time = Time.now
    i = 0
    while (log = readpage(file))
      case log
      when /^Index:/
        log.gsub!(/^(@@.*)\r$/, '\1')
	@patch << log
	next
      when *entries
	head = $&
	log = $'	# '
	time = Time.parse(head)
      else
	time = start_time
      end
      log.sub!(/\A\n+/, '')
      log.gsub!(/\s+$/, "\n")
      log.sub!(/\n+\z/, "\n")
      /\A\t/ =~ log or log.gsub!(/^(?!\s*$)/, "\t")
      @logs << [time, i-=1, head, log]
    end
    @logs.sort!.reverse!
    last = nil
    @logs.collect! do |log|
      log[1,1] = nil
      if last and last[0] == log[0] and last[1] == log[1]
        last[2] << "\n" << log[2]
        nil
      else
        last = log
      end
    end.compact!
    @newfiles = []
    @files = []
    @patch.map do |p|
      p.scan(%r"^diff.*\n[-*]{3} (/dev/null)?.*\n[-+]{3} ([^\t]*)") do
        @newfiles << $2 if $1
        @files << $2
      end
    end
    if @strip = strip
      re = %r"\A([^/]+/){0,#{@strip}}"
      @files.each {|p| p.sub!(re, '')}
      @newfiles.each {|p| p.sub!(re, '')}
    end
    if @prefix = prefix
      prefix += "/"
      @files.each {|p| p[0, 0] = prefix}
    end
  end

  def apply(cvs)
    open("|-", "w") do |f|
      unless f
	cvs.setdir
	yield if block_given?
	mod = cvs.module
	mod += "/#{@prefix}" if @prefix
	exec("patch", "-ZN", "-p#{@strip||0}", "-d", mod)
      end
      f.puts(@patch)
    end
    raise "patch failed" unless $? == 0
  end

  def log?
    not @logs.empty?
  end

  def mergelog(time)
    return if @logs.empty?
    log = [time, nil, ""]
    @logs.each {|t, h, l| log.last << l}
    @logs = [log]
  end

  def commitlog
    @logs.map {|time, head, log| log.gsub(/^\t/, "")}.join("\n")
  end

  def addlog(logfile)
    regulate = proc do |l|
      l.sub!(/[ \t]+$/, '')
      l.gsub!(/(?:\A|\G) {8}/, "\t")
      l.gsub!(/(?:\A|\G) {4}/, "\t")
      l.sub!(/^(\t+)(?![*\s])/, '\1  ')
      l
    end
    log = @logs[idx = 0]
    time = log[0] if log
    t = nil
    tmpfile = logfile + ".#$$.temp"
    bufsize = 0
    entry = nil
    pre = []
    open(logfile) do |inp|
      if $addentry and line = inp.gets
        if /^\w+.*\s#{$fullname}  <#$mailaddr>$/ =~ line
          inp.gets
        else
          inp.pos = 0
        end
      end
      open(tmpfile, "w") do |out|
	inp.each do |line|
          line = regulate[line]
          if !entry and !(entry = Entry.find {|re, *| re =~ line} and entry = entry[0])
            pre << line
            next
          elsif entry and entry !~ line
          elsif (t = Time.parse(line)) <= time
	    begin
	      out.puts log[1]||make_entry(time, entry), ""
              if pre
                out.print pre
                pre = nil
              end
              out.puts log[2], ""
	    end while log = @logs[idx += 1] and time = log[0] and t <= time
          end
          out.print line
          break unless log
        end unless @logs.empty?
        out.print pre if pre
        inp.each do |line|
	  out.print regulate[line]
          break if /^Local/ =~ line
	end
        inp.each do |line|
	  out.print line
	end
      end
    end
    File.rename(tmpfile, logfile)
  end
end

def apply(file, mod)
  if @output == true
    redir = proc {STDOUT.reopen(STDERR)}
  end
  patch = Patch.new(file, @prefix, @strip)
  patch.mergelog(@time) if @time
  commitlog = patch.commitlog
  raise "empty commit log" if @commitlog and commitlog.empty?
  cvs = CVS.new(mod, @root)
  cvs.noharm = @noharm
  cvs.workdir = @workdir ||= Dir.tmpdir(cvs.module)
  workdirs = []
  for rev in @revision.empty? ? [nil] : @revision
    cvs.revision = rev
    files = cvs.prefix(patch.files)
    files.unshift(cvs.logfile) if @changelog
    files -= newfiles = cvs.prefix(patch.newfiles)
    begin
      cvs.checkout(*files, &redir)
    rescue
      FileUtils::rm_rf(cvs.workdir) unless @keep
      raise
    end
    patch.apply(cvs, &redir)
    if @commit
      newfiles.empty? or cvs.add(*newfiles)
    else
      newfiles.each do |file|
        open(File.join(cvs.workdir, File.dirname(file), "CVS/Entries"), "a") do |f|
          file = File.basename(file)
          f.puts "/#{file}/0/Initial #{file}//"
        end
      end
    end
    patch.addlog(File.join(cvs.workdir, cvs.logfile)) if @changelog
    File.unlink(*Dir.glob(cvs.workdir+"/**/*~"))
    if @revision.size > 1
      dir = File.join(cvs.workdir, cvs.module)
      workdirs << cvs.module+"-#{rev || "HEAD"}"
      File.rename(dir, File.join(cvs.workdir, workdirs[-1]))
    end
  end
  if @output
    commitlog << "\n\f" unless commitlog.empty?
    begin
      f = STDOUT
      filter = proc do |p|
        p.each {|l| l.sub!(/^(@@.*)\r$/, '\1'); f.print(l)}
        Process.waitpid(p.pid)
        $? == 0
      end
      if @output == true
	puts(commitlog)
	return cvs.diff(&filter)
      else
	File.open(@output, "w") do |f|
	  f.puts(commitlog) unless commitlog.empty?
	  f.flush
	  return cvs.diff(&filter)
	end
      end
    ensure
      FileUtils::rm_rf(cvs.workdir) unless @keep
    end
  end
  if @commit
    cvs.root = @commit if String === @commit
    cvs.checkin(commitlog, *workdirs)
    FileUtils::rm_rf(cvs.workdir) unless @keep
  else
    open(File.join(cvs.workdir, "commitlog"), "w") {|f|
      f.write commitlog
    }
  end
end

@commit = @time = @prefix = @workdir = @output = @strip = nil
@changelog = true
@noharm = false
@revision = []
opt = nil
ARGV.options do |opt|
  opt.banner << " module patch-files..."
  def (rep = /\A:/).match(s)
    super or File.directory?(s) and s
  end
  opt.def_option("-c", "--[no-]commit [repository]", rep, "do commit") {|rep|@commit = rep.nil? || rep}
  opt.def_option("-d", "--cvs-root=ROOT", "override the root of CVS tree") {|root|@root = root}
  opt.def_option("-l", "--no-changelog", "no update ChangeLog") {|n|@changelog = n}
  opt.def_option("-p", "--prefix=path", String, "module prefix") {|p|@prefix = p}
  opt.def_option("-s", "--strip=num", Integer, "strip prefix num") {|n|@strip = n}
  opt.def_option("-t", "--time [time]", Time, "changelog time") {|t|@time = t || Time.now}
  opt.def_option("-r", "--revision [revision]", String, "apply to specified revision") {|r|@revision << r}
  opt.def_option("-k", "--[no-]keep", "keep working set after commit") {|keep|@keep = keep}
  opt.def_option("-w", "--working-directory=DIR", "working directory") {|dir|@workdir = dir}
  opt.def_option("-o", "--output FILE", "re-make patch") {|o|@output = o == '-' || o || true}
  opt.def_option("-n", "--no-harm", "do not change repository") {|o|@noharm = o}
  opt.def_option("-a", "--add-entry", "add entry if same auther") {|o|$addentry = o}
  opt.def_option("-u", "--user USER[:MAILADDR]", "author name", /\A(.*?)(?::(.*))?\z/) {|n, u, m|
    $fullname = u
    $mailaddr = m if m
  }
  opt.parse!
  ARGV.size > 0
end or abort ARGV.options.to_s
if @commit and @output
  abort "$0: --commit and --output option are not compatible"
end
if ARGV.size == 1 or (mod = ARGV.shift) == "."
  mod = nil
end
apply(ARGF, mod)
