#!/usr/bin/env ruby # TODO: # * Handle a conflict in a remote commit that is in a renamed file. $:.unshift File.dirname(__FILE__) require 'enhance' require 'optparse' require 'ostruct' require 'fileutils' require 'readline' opts = OpenStruct.new op = OptionParser.new do |o| o.on "-z", "--analyze", "Output information on what update would do" do opts.analyze = true end o.on "-v", "--verbose", "Be verbose" do opts.verbose = true end o.on "--debug", "Show all git commands run" do Grit.debug = true end o.on "-q", "--quiet", "Show the minimal output" do opts.quiet = true end end op.parse!(ARGV) STDOUT.sync = true class Update HELP = <<-TXT You're currently inside the conflict resolver. The following commands are available to help you. When the conflict resolver is first started, the contents of the file will contain the file populated with conflict markers for you to edit. [D]iff View the diffs between the (original version and local version) and (original version and remote version). [E]dit Launch your editor to edit the file. [T]ool Run git-mergetool on the file. [O]riginal Set the contents of the file to the original version. This is version from the common ancestor of your commit and the remote commit. [M]ine Set the contents of the file to be your version. [R]emote Set the contents of the file to be the remote version. co[N]flict Set the contents of the file to contain the merged between the local version and remote version, with conflict markers. [P]rompt Launch a subshell to deal with the conflict. Simply exit from the shell to continue with conflict resolution. [I]nfo View information about the commit and the current file. [A]bort Cancel the update all together, restore everything to before the update was started. [C]ontinue You're done dealing with this conflict, move on to the next one. [H]elp Detail all available options, you're looking at it now. TXT def initialize(opts) @opts = opts @repo = Grit::Repo.current @current = @repo.resolve_rev "HEAD" @branch = @repo.git.symbolic_ref({:q => true}, "HEAD").strip @branch_name = @branch.gsub %r!^refs/heads/!, "" @origin_ref = @repo.merge_ref @branch_name unless @origin_ref puts "Sorry, it appears you're current branch is not setup with merge info." puts "Please set 'branch.#{@branch_name}.remote' and 'branch.#{@branch_name}.merge'" puts "and try again." exit 1 end end def fetch print "Fetching new commits: " out = @repo.git.fetch :timeout => false puts "done." # TODO parse +out+ for details to show the user. end def includes_conflict_markers?(path) /^<<<<<<< HEAD/.match(File.read(path)) end def cat_file(ref, file) File.open(file, "w") do |f| f << @repo.git.cat_file({}, ref) end end def handle_unmerged(patch_info, files) files.each do |name, info| system "cp #{name} .git/with_markers" puts puts "Conflict discovered in '#{name}'" loop do # If there are conflict markers, default is edit. if includes_conflict_markers?(name) default = "E" # otherwise it's continue. else default = "C" end ans = Readline.readline "Select: [D]iff, [E]dit, [C]ontinue, [H]elp: [#{default}] " ans = default if ans.empty? want = ans.downcase[0] case want when ?d orig = ".git/diff/original/#{name}" FileUtils.mkdir_p File.dirname(orig) cat_file info.original, orig mine = ".git/diff/mine/#{name}" FileUtils.mkdir_p File.dirname(mine) cat_file info.mine, mine remote = ".git/diff/remote/#{name}" FileUtils.mkdir_p File.dirname(remote) cat_file info.yours, remote system "cd .git/diff; diff -u original/#{name} mine/#{name}" system "cd .git/diff; diff -u original/#{name} remote/#{name}" system "rm -rf .git/diff" when ?e system "#{ENV['EDITOR']} #{name}" when ?t system "git mergetool #{name}" when ?o cat_file info.original, name when ?m cat_file info.mine, name when ?r cat_file info.yours, name when ?n system "cp .git/with_markers #{name}" when ?p puts "Starting a sub-shell to handle conflicts for #{name}." puts "Exit the shell to continue resolution." system "$SHELL" when ?i puts "Current file: #{name}" puts "Current commit:" puts " Subject: #{patch_info[:subject]}" puts " Date: #{patch_info[:date]}" puts " Author: #{patch_info[:author]} (#{patch_info[:email]})" when ?a raise "abort!" when ?h puts HELP when ?c if includes_conflict_markers?(name) puts puts "It looks like this file still contains conflict markers." a = Readline.readline "Are you sure that you want to commit it? [Y/N]: " break if a.downcase[0] == ?y else break end else puts "Unknown option. Try again." end end File.unlink ".git/with_markers" rescue nil @repo.git.add({}, name) end end def analyze puts "Automatically merging in refs from: #{@origin_ref} / #{@origin[0,7]}" puts "Closest ancestor between HEAD and origin: #{@common[0,7]}" puts if @to_receive.empty? puts "Current history is up to date." exit 0 end puts "#{@to_receive.size} new commits." if @opts.verbose system "git log --pretty=oneline #{@common}..#{@origin_ref}" puts end puts "#{@to_replay.size} commits to adapt." if @opts.verbose system "git log --pretty=oneline #{@common}..HEAD" puts end end def run fetch @origin = @repo.resolve_rev @origin_ref @common = @repo.find_ancestor(@origin, @current) @to_replay = @repo.revs_between(@common, @current) @to_receive = @repo.revs_between(@common, @origin) if @opts.analyze analyze exit 0 end if @to_receive.empty? puts "Up to date." exit 0 end if @opts.verbose puts "Extracting commits between #{@common[0,7]} and HEAD..." end # DANGER. Before here, we can abort anytime, after here, we're making # changes, so we need to be able to recover. # begin port_changes rescue Exception => e puts "Error detected, aborting update: #{e.message} (#{e.class})" puts e.backtrace recover exit 1 end end def recover @repo.git.reset({:hard => true}, @current) @repo.git.checkout({}, @branch.gsub(%r!^refs/heads/!, "")) if @used_wip @repo.git.reset({:mixed => true}, "HEAD^") end system "rm -rf #{Grit.rebase_dir}" rescue nil end def sh(cmd) Grit.log cmd if Grit.debug out = `#{cmd}` Grit.log out if Grit.debug end def port_changes # Switch back in time so we can re-apply commits. checkout # will return non-zero if there it can't be done. In that case # we perform a WIP commit, and unwind that WIP commit later, # leaving the working copy the same way it was. @used_wip = false list = @repo.git.ls_files(:m => true).split("\n") if list.size > 0 @repo.git.commit({:m => "++WIP++", :a => true}) @used_wip = true # Because we've introduced a new commit, we need to repoint current. @current = @repo.resolve_rev "HEAD" # And the list of commits to replay. @to_replay = @repo.revs_between(@common, @current) # Ok, try again. error = @repo.git.checkout({:q => true}, @origin) if $?.exitstatus != 0 # Ok, give up. recover # Now tell the user what happened. puts "ERROR: Sorry, 'git checkout' can't figure out how to properly switch" puts "the working copy. Please fix this and run 'git update' again." puts "Here is the error that 'git checkout' reported:" puts error exit 1 end else @repo.git.checkout({:q => true}, @origin) end sh "git format-patch --full-index --stdout #{@common}..#{@current} > .git/update-patch" out = sh "git am --rebasing < .git/update-patch 2> /dev/null" while $?.exitstatus != 0 info = @repo.am_info if @opts.verbose if info[:subject] == "++WIP++" puts "Conflict detected in working copy." else puts "Conflict detected applying: #{info[:subject]}" end end unmerged = @repo.unmerged_files handle_unmerged info, unmerged if @repo.to_be_committed.empty? out = @repo.git.am({:skip => true, "3" => true}) else out = @repo.git.am({:resolved => true, "3" => true}) end end # Remove the patch we created contain all the rebased commits File.unlink ".git/update-patch" rescue nil rev = @repo.resolve_rev "HEAD" # Update the branch ref to point to our new commit @repo.git.update_ref({:m => "updated"}, @branch, rev, @current) @repo.git.symbolic_ref({}, "HEAD", @branch) # If we inserted a WIP commit on the top, remove the commit, but leave # the work. if @used_wip @repo.git.reset({:mixed => true}, "HEAD^") end puts puts "Updated. Imported #{@to_receive.size} commits, HEAD now pointed to #{rev[0,7]}." puts unless @opts.quiet system "git diff --stat #{@common}..#{@origin}" end end end Update.new(opts).run