require 'tmpdir' require 'logger' module RIM # raised when there is an error emitted by git call class GitException < Exception attr_reader :cmd, :exitstatus, :out def initialize(cmd, exitstatus, out) super("git \"#{cmd}\" => #{exitstatus}\n#{out}") @cmd = cmd @exitstatus = exitstatus @out = out end end class GitSession attr_reader :execute_dir def initialize(logger, execute_dir, arg = {}) @execute_dir = execute_dir if arg.is_a?(Hash) @work_dir = arg.has_key?(:work_dir) ? arg[:work_dir] : "" @git_dir = arg.has_key?(:git_dir) ? arg[:git_dir] : "" end @logger = logger end def self.logger=(logger) @logger = logger end def self.open(execute_dir, options = {}) log = @logger || Logger.new($stdout) self.new(log, execute_dir, options) end def self.next_invocation_id @invocation_id ||= 0 @invocation_id += 1 end class Status attr_accessor :lines # X Y Meaning # ------------------------------------------------- # [MD] not updated # M [ MD] updated in index # A [ MD] added to index # D [ M] deleted from index # R [ MD] renamed in index # C [ MD] copied in index # [MARC] index and work tree matches # [ MARC] M work tree changed since index # [ MARC] D deleted in work tree # ------------------------------------------------- # D D unmerged, both deleted # A U unmerged, added by us # U D unmerged, deleted by them # U A unmerged, added by them # D U unmerged, deleted by us # A A unmerged, both added # U U unmerged, both modified # ------------------------------------------------- # ? ? untracked # ! ! ignored # ------------------------------------------------- class Line attr_accessor :istat, :wstat, :file, :rename def untracked? istat == "?" && wstat == "?" end def ignored? istat == "!" && wstat == "!" end def unmerged? istat == "D" && wstat == "D" || istat == "A" && wstat == "A" || istat == "U" || wstat == "U" end end end # returns the current branch def current_branch out = execute "git branch" out.split("\n").each do |l| if l =~ /^\*\s+(\S+)/ return $1 end end nil end # check whether branch exists def has_branch?(branch) execute("git show-ref refs/heads/#{branch}") do |b, e| return !e end end # check whether remote branch exists def has_remote_branch?(branch) out = execute("git ls-remote --heads") out.split("\n").each do |l| return true if l.split(/\s+/)[1] == "refs/heads/#{branch}" end false end # check whether remote repository is valid def has_valid_remote_repository?() execute("git ls-remote") do |b, e| return !e end end # checks whether the first (ancestor) revision is is ancestor of the second (child) revision def is_ancestor?(ancestor, child) execute("git merge-base --is-ancestor #{ancestor} #{child}") do |b, e| return !e end end # returns the parent commits of rev as SHA-1s # returns an empty array if there are no parents (e.g. orphan or initial) def parent_revs(rev) out = execute "git rev-list -n 1 --parents #{rev} --" out.strip.split[1..-1] end # returns the SHA-1 representation of rev def rev_sha1(rev) sha1 = nil execute "git rev-list -n 1 #{rev} --" do |out, e| sha1 = out.strip if !e end sha1 end # returns the SHA-1 representations of the heads of all remote branches def remote_branch_revs out = execute "git show-ref" out.split("\n").collect { |l| if l =~ /refs\/remotes\// l.split[0] else nil end }.compact end # all commits reachable from rev which are not ancestors of remote branches def all_reachable_non_remote_revs(rev) out = execute "git rev-list #{rev} --not --remotes --" out.split("\n") end # export file contents of rev to dir # if +paths+ is given and non-empty, checks out only those parts of the filesystem tree # does not remove any files from dir which existed before def export_rev(rev, dir, paths=[]) paths = paths.dup loop do path_args = "" # max command line length on Windows XP and higher is 8191 # consider the following extra characters which will be added: # up to 3 paths in execute, 1 path for tar, max path length 260 = 1040 # plus some "glue" characters, plus the last path item with 260 max; # use 6000 to be on the safe side while !paths.empty? && path_args.size < 6000 path_args << " " path_args << paths.shift end execute "git archive --format tar #{rev} #{path_args} | tar -x -C #{dir}" break if paths.empty? end end # checks out rev to a temporary directory and yields this directory to the given block # if +paths+ is given and non-empty, checks out only those parts of the filesystem tree # returns the value returned by the block def within_exported_rev(rev, paths=[]) Dir.mktmpdir("rim") do |d| c = File.join(d, "content") FileUtils.mkdir(c) export_rev(rev, c, paths) # return contents of yielded block # mktmpdir returns value return by our block yield c FileUtils.rm_rf(c) # retry to delete if it hasn't been deleted yet # this could be due to Windows keeping the files locked for some time # this is especially a problem if the machine is at its limits retries = 600 while File.exist?(c) && retries > 0 sleep(0.1) FileUtils.rm_rf(c) retries -= 1 end if File.exist?(c) @logger.warn "could not delete temp dir: #{c}" end end end def uncommited_changes? # either no status lines are all of them due to ignored items !status.lines.all?{|l| l.ignored?} end def current_branch_name out = execute "git rev-parse --abbrev-ref HEAD" out.strip end ChangedFile = Struct.new(:path, :kind) # returns a list of all files which changed in commit +rev+ # together with the kind of the change (:modified, :deleted, :added) # # if +from_rev+ is given, lists changes between +from_rev and +rev+ # with one argument only, no changes will be returned for merge commits # use the two argument variant for merge commits and decide for one parent def changed_files(rev, rev_from=nil) out = execute "git diff-tree -r --no-commit-id #{rev_from} #{rev}" out.split("\n").collect do |l| cols = l.split path = cols[5] kind = case cols[4] when "M" :modified when "A" :added when "D" :deleted else nil end ChangedFile.new(path, kind) end end # 3 most significant numbers of git version of nil if it can't be determined def git_version out = execute("git --version") if out =~ /^git version (\d+\.\d+\.\d+)/ $1 else nil end end def status(dir = nil) # -s short format # --ignored show ignored out = execute "git status -s --ignored #{dir}" parse_status(out) end def execute(cmd) raise "git command has to start with 'git'" unless cmd.start_with? "git " cmd.slice!("git ") # remove any newlines as they will cause the command line to end prematurely cmd.gsub!("\n", "") options = ((!@execute_dir || @execute_dir == ".") ? "" : " -C #{@execute_dir}") \ + (@work_dir.empty? ? "" : " --work-tree=#{File.expand_path(@work_dir)}") \ + (@git_dir.empty? ? "" : " --git-dir=#{File.expand_path(@git_dir)}") cmd = "git#{options} #{cmd} 2>&1" out = `#{cmd}` # make sure we don't run into any encoding misinterpretation issues out.force_encoding("binary") exitstatus = $?.exitstatus invid = self.class.next_invocation_id.to_s.ljust(4) @logger.debug "git##{invid} \"#{cmd}\" => #{exitstatus}" out.split(/\r?\n/).each do |ol| @logger.debug "git##{invid} out : #{ol}" end exception = exitstatus != 0 ? GitException.new(cmd, exitstatus, out) : nil if block_given? yield out, exception elsif exception raise exception end out end private def parse_status(out) status = Status.new status.lines = [] out.split(/\r?\n/).each do |l| sl = Status::Line.new sl.istat, sl.wstat = l[0], l[1] f1, f2 = l[3..-1].split(" -> ") f1 = unquote(f1) f2 = unquote(f2) if f2 sl.file = f1 sl.rename = f2 status.lines << sl end status end def unquote(s) if s[0] == "\"" && s[-1] == "\"" s = s[1..-2] s.gsub!("\\\\", "\\") s.gsub!("\\\"", "\"") s.gsub!("\\t", "\t") s.gsub!("\\r", "\r") s.gsub!("\\n", "\n") end s end end def RIM.git_session(execute_dir, options = {}) s = GitSession.open(execute_dir, options) yield s end end