#!/usr/bin/env ruby

#
# Copyright (C) 2013-2014 Felipe Contreras
#
# This file may be used under the terms of the GNU GPL version 2.
#

require 'fileutils'

$merged = []
$actions = []

$need_rebuild = false
$branches_to_add = []
$autocontinue = false

NULL_SHA1 = '0' * 40

def die(*args)
  fmt = args.shift
  $stderr.printf("fatal: %s\n" % fmt, *args)
  exit 128
end

def git_editor(*args)
  editor = %x[git var GIT_EDITOR].chomp.split(' ')
  system(*editor, *args)
end

class ParseOpt
  attr_writer :usage

  class Option
    attr_reader :short, :long, :help

    def initialize(short, long, help, &block)
      @block = block
      @short = short
      @long = long
      @help = help
    end

    def call(v)
      @block.call(v)
    end
  end

  def initialize
    @list = {}
  end

  def on(short = nil, long = nil, help = nil, &block)
    opt = Option.new(short, long, help, &block)
    @list[short] = opt if short
    @list[long] = opt if long
  end

  def parse
    if ARGV.member?('-h') or ARGV.member?('--help')
      usage
      exit 0
    end
    seen_dash = false
    ARGV.delete_if do |cur|
      opt = val = nil
      next false if cur[0,1] != '-' or seen_dash
      case cur
      when '--'
        seen_dash = true
        next true
      when /^--no-(.+)$/
        opt = @list[$1]
        val = false
      when /^-([^-])(.+)?$/, /^--(.+?)(?:=(.+))?$/
        opt = @list[$1]
        val = $2 || true
      end
      if opt
        opt.call(val)
        true
      else
        usage
        exit 1
      end
    end
  end

  def usage
    def fmt(prefix, str)
      return str ? prefix + str : nil
    end
    puts 'usage: %s' % @usage
    @list.values.uniq.each do |opt|
      s = '    '
      s << ''
      s << [fmt('-', opt.short), fmt('--', opt.long)].compact.join(', ')
      s << ''
      s << '%*s%s' % [26 - s.size, '', opt.help] if opt.help
      puts s
    end
  end

end

def parse_merge(other, commit, msg, body)
  case msg
  when /^Merge branch '(.*)'/
    ref = 'refs/heads/' + $1
  when /^Merge remote branch '(.*)'/
    prefix = 'refs/'
    ref = 'refs/' + $1
  else
    $stderr.puts "Huh?: #{msg}"
    return
  end

  tip = %x[git name-rev --refs="#{ref}" 2> /dev/null].chomp
  merged = %x[git name-rev --refs="#{ref}" "#{other}" 2> /dev/null].chomp
  if merged =~ /\h{40} (.*)/
    merged = $1
  end
  merged = "merge #{merged}"
  merged += "\n\n" + body.gsub(/^/, '  ') unless body.empty?
  merged
end

class Branch

  attr_reader :name, :ref, :int

  def initialize(name)
    @name = name
  end

  def get
    if @name
      @ref = %x[git rev-parse --symbolic-full-name "refs/heads/#{@name}"].chomp
      die "no such branch: #{@name}" unless $?.success?
    else
      @ref = %x[git symbolic-ref HEAD].chomp
      die "HEAD is detached, could not figure out which integration branch to use" unless $?.success?
      @name = @ref.gsub(%r{^refs/heads/}, '')
    end

    @int = @ref.gsub(%r{^refs/heads/}, 'refs/int/')

    system(*%w[git rev-parse --quiet --verify], @int, :out => File::NULL)
    die "Not an integration branch: #{@name}" unless $?.success?
  end

  def create(base = nil)
    @ref = %x[git check-ref-format --normalize "refs/heads/#{@name}"].chomp
    die "invalid branch name: #{@name}" unless $?.success?

    if base
      system(*%w[git rev-parse --quiet --verify], "#{base}^{commit}", :out => File::NULL)
      die "no such commit: #{base}" unless $?.success?
    else
      base = 'master'
    end

    @int = @ref.gsub(%r{^refs/heads/}, 'refs/int/')

    system(*%w[git update-ref], @ref, base, NULL_SHA1)
    write_instructions("base #{base}\n")
    system(*%w[git checkout], @name)
    puts "Integration branch #{@name} created."
  end

  def generate(base = nil)
    if @name
      @ref = %x[git rev-parse --symbolic-full-name "refs/heads/#{@name}"].chomp
      die "no such branch: #{@name}" unless $?.success?
    else
      @ref = %x[git symbolic-ref HEAD].chomp
      die "HEAD is detached, could not figure out which integration branch to use" unless $?.success?
      @name = @ref.gsub(%r{^refs/heads/}, '')
    end

    if base
      system(*%w[git rev-parse --quiet --verify], "#{base}^{commit}", :out => File::NULL)
      die "no such commit: #{base}" unless $?.success?
    else
      base = 'master'
    end

    @int = @ref.gsub(%r{^refs/heads/}, 'refs/int/')

    series = []

    series << "base #{base}"

    IO.popen(%w[git log --no-decorate --format=%H%n%B -z --reverse --first-parent] + ["^#{base}", @name]) do |io|
      io.each("\0") do |l|
        commit, summary, body = l.chomp("\0").split("\n", 3)
        body.lstrip!
        other = %x[git rev-parse -q --verify "#{commit}^2"].chomp
        if not other.empty?
          body.gsub!(/\n?(\* .*:.*\n.*)/m, '')
          series << parse_merge(other, commit, summary, body)
        end
      end
    end

    write_instructions(series.join("\n") + "\n")

    puts "Integration branch #{@name} generated."
  end

  def read_instructions
    %x[git cat-file blob #{@int}:instructions].chomp
  end

  def write_instructions(content)
    insn_blob = insn_tree = insn_commit = nil

    parent = %x[git rev-parse --quiet --verify #{@int}].chomp
    parent_tree = %x[git rev-parse --quiet --verify #{@int}^{tree}].chomp

    parent = nil if parent.empty?

    IO.popen(%[git hash-object -w --stdin], 'r+') do |io|
      io.write(content)
      io.close_write
      insn_blob = io.read.chomp
    end
    die "Failed to write instruction sheet blob object" unless $?.success?

    IO.popen(%[git mktree], 'r+') do |io|
      io.printf "100644 blob %s\t%s\n", insn_blob, 'instructions'
      io.close_write
      insn_tree = io.read.chomp
    end
    die "Failed to write instruction sheet tree object" unless $?.success?

    # If there isn't anything to commit, stop now.
    return if insn_tree == parent_tree

    op = parent ? 'Update' : 'Create'
    opts = parent ? ['-p', parent] : []
    opts << insn_tree
    IO.popen(%w[git commit-tree] + opts, 'r+') do |io|
      io.write("#{op} integration branch #{@int}")
      io.close_write
      insn_commit = io.read.chomp
    end
    die "Failed to write instruction sheet commit" unless $?.success?

    system(*%w[git update-ref], @int, insn_commit, parent || NULL_SHA1)
    die "Failed to update instruction sheet reference" unless $?.success?
  end

end

class Integration

  attr_reader :commands

  class Stop < Exception
  end

  class Pause < Exception
  end

  @@map = { '.' => :cmd_dot }

  def initialize(obj)
    self.load(obj)
  end

  def load(obj)
    cmd, args = nil
    msg = ""
    cmds = []
    obj.each_line do |l|
      l.chomp!
      case l
      when ''
      when /^\s(.*)$/
        msg << $1
      when /(\S+) ?(.*)$/
        cmds << [cmd, args, msg] if cmd
        cmd, args = [$1, $2]
        msg = ""
      end
    end
    cmds << [cmd, args, msg] if cmd
    @commands = cmds
  end

  def self.run(obj)
    self.new(obj).run
  end

  def run
    begin
      while cmd = @commands.first
        finalize_command(*cmd)
        @commands.shift
      end
    rescue Integration::Stop => e
      stop(e.message)
    rescue Integration::Pause => e
      @commands.shift
      stop(e.message)
    else
      finish
    end
  end

  def finalize_command(cmd, args, message)
    begin
      fun = @@map[cmd] || "cmd_#{cmd}".to_sym
      send(fun, message, *args)
    rescue NoMethodError
      raise Integration::Stop, "Unknown command: #{cmd}"
    end
  end

  def finish
    system(*%w[git update-ref], $branch.ref, 'HEAD', File.read($start_file))
    system(*%w[git symbolic-ref], 'HEAD', $branch.ref)
    FileUtils.rm_rf($state_dir)
    system(*%w[git gc --auto])
    puts "Successfully re-integrated #{$branch.name}."
  end

  def stop(msg = nil)
    File.open($insns, 'w') do |f|
      @commands.each do |cmd, args, msg|
        str = "%s %s\n" % [cmd, args]
        str += "%s\n" % msg if msg and not msg.empty?
        f.write(str)
      end
    end

    File.write($merged_file, $merged.join("\n"))

    $stderr.puts(msg) if msg and ! msg.empty?
    $stderr.puts <<EOF

Once you have resolved this, run:

  git reintegrate --continue

NOTE: Any changes to the instruction sheet will not be saved.
EOF
    exit 1
  end

end

def do_edit
  ref = $branch.int
  branch = $branch.ref

  edit_file = "#{$git_dir}/GIT-INTEGRATION"

  content = $branch.read_instructions
  if not $branches_to_add.empty?
    content += "\n" + $branches_to_add.map { |e| "merge #{e}" }.join("\n") + "\n"
  end
  comment = <<EOF

Format:
 command: args

    Indented lines form a comment for certain commands.
    For other commands these are ignored.

Lines beginning with $comment_char are stripped.

Commands:
 base		Resets the branch to the specified state.  Every integration
		instruction list should begin with a "base" command.
 merge		Merges the specified branch.  Extended comment lines are
		added to the commit message for the merge.
 .		The command is ignored.
EOF
  File.write(edit_file, content + "\n" + comment.gsub(/^/, '# \1'))

  if $edit
    git_editor(edit_file) || die
  end

  content = File.read(edit_file).gsub(/^# .*?\n/m, '')

  $branch.write_instructions(content)
end

def cmd_base(message, base)
  puts "Resetting to base #{base}..."
  system(*%w[git reset --quiet --hard], base)
  raise Integration::Stop, "Failed to reset to base #{base}" unless $?.success?
end

def deindent(msg)
  msg = msg.lstrip
  indent = msg.lines.first.gsub(/^([ \t]*).*$/, '\1')
  return msg.gsub(/^#{indent}/, '')
end

def cmd_merge(message, branch_to_merge, *args)
  merge_msg = "Merge branch '#{branch_to_merge}' into #{$branch.name}\n"
  merge_msg += "\n#{deindent(message)}\n" unless message.empty?

  merge_opts = args
  merge_opts += %w[--quiet --no-ff]
  merge_opts += ['-m', merge_msg]

  puts "Merging branch #{branch_to_merge}..."
  system(*%w[git merge], *merge_opts, branch_to_merge)
  if not $?.success?
    if $autocontinue && %x[git rerere remaining].chomp == ''
      system(*%w[git commit --no-edit --no-verify -a])
      raise Integration::Stop, '' unless $?.success?
    else
      raise Integration::Stop, ''
    end
  end
  $merged << "#{branch_to_merge}\n"
end

def cmd_fixup(message, fixup_commit, *args)
  puts "Fixing up with #{fixup_commit}"

  system(*%w[git cherry-pick --no-commit], fixup_commit) &&
  system({ 'EDITOR' => ':' }, *%w[git commit --amend -a])
  raise Integration::Stop, '' unless $?.success?
end

def cmd_commit(message, *args)
  puts "Emtpy commit"

  system(*%w[git commit --allow-empty -m], message)
  raise Integration::Stop, '' unless $?.success?
end

def cmd_pause(message, *args)
  raise Integration::Pause, (message || 'Pause')
end

def cmd_dot(message, *args)
end

def require_clean_work_tree(action = nil, msg = nil, quiet = false)
  system(*%w[git update-index -q --ignore-submodules --refresh])
  errors = []

  system(*%w[git diff-files --quiet --ignore-submodules])
  errors << "Cannot #{action}: You have unstaged changes." unless $?.success?

  system(*%w[git diff-index --cached --quiet --ignore-submodules HEAD --])
  if not $?.success?
    if errors.empty?
      errors << "Cannot #{action}: Your index contains uncommitted changes."
    else
      errors << "Additionally, your index contains uncommitted changes."
    end
  end

  if not errors.empty? and not quiet
    errors.each do |e|
      $stderr.puts(e)
    end
    $stderr.puts(msg) if msg
    exit 1
  end

  return errors.empty?
end

def do_rebuild
  branch = $branch.ref
  ref = $branch.int

  require_clean_work_tree('integrate', "Please commit or stash them.")

  orig_head = %x[git rev-parse --quiet --verify "#{branch}^{commit}"].chomp
  system(*%w[git update-ref ORIG_HEAD], orig_head)

  system(*%w[git checkout --quiet], "#{branch}^0")
  die "could not detach HEAD" unless $?.success?

  FileUtils.mkdir_p($state_dir)

  File.write($head_file, branch)
  commit = %x[git rev-parse --quiet --verify #{branch}].chomp
  File.write($start_file, commit)

  inst = $branch.read_instructions
  die "Failed to read instruction list for branch #{$branch.name}" unless $?.success?

  File.write($insns, inst)
  Integration.run(inst)
end

def get_head_file
  die "no integration in progress" unless test('f', $head_file)
  branch_name = File.read($head_file).gsub!(%r{^refs/heads/}, '')
  branch = Branch.new(branch_name)
  branch.get
  return branch
end

def do_continue
  $branch = get_head_file

  if File.exists?("#{$git_dir}/MERGE_HEAD")
    # We are being called to continue an existing operation,
    # without the user having manually committed the result of
    # resolving conflicts.
    system(*%w[git update-index --ignore-submodules --refresh]) &&
      system(*%w[git diff-files --quiet --ignore-submodules]) ||
      die("You must edit all merge conflicts and then mark them as resolved using git add")

    system(*%w[git commit --quiet --no-edit])
    die "merge_head" unless $?.success?
  end

  $merged = File.read($merged_file).split("\n")

  File.open($insns) do |f|
    Integration.run(f)
  end
end

def do_abort
  $branch = get_head_file

  system(*%w[git symbolic-ref HEAD], $branch.ref) &&
    system(*%w[git reset --hard], $branch.ref) &&
    FileUtils.rm_rf($state_dir)
end

def status_merge(branch_to_merge = nil)
  if not branch_to_merge
    $stderr.puts "no branch specified with 'merge' command"
    return
  end
  $status_base ||= 'master'

  if ! system(*%w[git rev-parse --verify --quiet], "#{branch_to_merge}^{commit}", :out => File::NULL)
    state = "."
    verbose_state = "branch not found"
  elsif system(*%w[git merge-base --is-ancestor], branch_to_merge, $status_base)
    state = "+"
    verbose_state = "merged to #{$status_base}"
  elsif system(*%w[git-merge-base --is-ancestor], branch_to_merge, $branch.name)
    state = "*"
    verbose_state = "up-to-date"
  else
    state = "-"
    verbose_state = "branch changed"
  end

  printf("%s %-*s(%s)", state, $longest_branch, branch_to_merge, verbose_state)
  puts $message.gsub(/^./, '     \&') if $message
  puts

  log = %x[git --no-pager log --oneline --cherry "#{$status_base}...#{branch_to_merge}" -- 2> /dev/null].chomp
  print(log.gsub(/^/, '  ') + "\n\n") unless log.empty?
end

def status_dot(*args)
  args.each do |arg|
    puts ". #{arg}\n"
  end
  puts $message.gsub(/^./, '     \&') if $message
end

def do_status
  inst = $branch.read_instructions
  die "Failed to read instruction list for branch #{$branch.name}" unless $?.success?

  int = Integration.new(inst)
  cmds = int.commands

  $longest_branch = cmds.map do |cmd, args, msg|
    next 0 if cmd != 'merge'
    args.split(' ').first.size
  end.max

  cmds.each do |cmd, args, msg|
    case cmd
    when 'base'
      $status_base = args
    when 'merge'
      status_merge(*args)
    when '.'
      status_dot(*args)
    else
      $stderr.puts "unhandled command: #{cmd} #{args}"
    end
  end
end

opts = ParseOpt.new
opts.usage = 'git reintegrate'

opts.on('c', 'create', 'create a new integration branch') do |v|
  $create = true
  $need_rebuild = true
end

opts.on('e', 'edit', 'edit the instruction sheet for a branch') do |v|
  $edit = true
  $need_rebuild = true
end

opts.on('r', 'rebuild', 'rebuild an integration branch') do |v|
  $rebuild = v
end

opts.on(nil, 'continue', 'continue an in-progress rebuild') do |v|
  $actions << :continue
end

opts.on(nil, 'abort', 'abort an in-progress rebuild') do |v|
  $actions << :abort
end

opts.on('g', 'generate', 'generate instruction sheet') do |v|
  $generate = true
end

opts.on('a', 'add', '') do |v|
  $branches_to_add << v
  $need_rebuild = true
end

opts.on(nil, 'autocontinue', '') do |v|
  $autocontinue = v
end

opts.on(nil, 'cat', '') do |v|
  $cat = v
end

opts.on('s', 'status', '') do |v|
  $status = true
end

%x[git config --bool --get integration.autocontinue].chomp == "true" &&
$autocontinue = true

opts.parse

$git_dir = %x[git rev-parse --git-dir].chomp

$state_dir = "#{$git_dir}/integration"
$start_file = "#{$state_dir}/start-point"
$head_file = "#{$state_dir}/head-name"
$merged_file = "#{$state_dir}/merged"
$insns = "#{$state_dir}/instructions"

case $actions.first
when :continue
  do_continue
when :abort
  do_abort
end

$branches_to_add.each do |branch|
  system(*%w[git rev-parse --quiet --verify], "#{branch}^{commit}", :out => File::NULL)
  die "not a valid commit: #{branch}" unless $?.success?
end

$branch = Branch.new(ARGV[0])
if $create
  $branch.create(ARGV[1])
elsif $generate
  $branch.generate(ARGV[1])
else
  $branch.get
end

if $edit || ! $branches_to_add.empty?
  do_edit
end

if $cat
  puts $branch.read_instructions
end

if $rebuild == nil && $need_rebuild == true
  %x[git config --bool --get integration.autorebuild].chomp == "true" &&
  $rebuild = true
end

if $rebuild
  do_rebuild
end

if $status
  do_status
end
