aanand/git-up

View on GitHub
lib/git-up.rb

Summary

Maintainability
D
1 day
Test Coverage
require 'colored'
require 'grit'

require 'git-up/version'

class GitUp
  def run(argv)
    @fetch = true
    
    process_args(argv)

    if @fetch
      command = ['git', 'fetch', '--multiple']
      command << '--prune' if prune?
      command += config("fetch.all") ? ['--all'] : remotes

      # puts command.join(" ") # TODO: implement a 'debug' config option
      system(*command)
      raise GitError, "`git fetch` failed" unless $? == 0
    end
    
    @remote_map = nil # flush cache after fetch

    Grit::Git.with_timeout(0) do
      with_stash do
        returning_to_current_branch do
          rebase_all_branches
        end
      end
    end

    check_bundler
  rescue GitError => e
    puts e.message
    exit 1
  end

  def process_args(argv)
    banner = <<BANNER
Fetch and rebase all remotely-tracked branches.

    $ git up
    master         #{"up to date".green}
    development    #{"rebasing...".yellow}
    staging        #{"fast-forwarding...".yellow}
    production     #{"up to date".green}

    $ git up --no-fetch   # do not fetch, only rebase
    $ git up --version    # print version info
    $ git up --help       # print this message

There are no interesting command-line options, but
there are a few `git config` variables you can set.
For info on those and more, check out the man page:

    $ git up man

Or install it to your system, so you can get to it with
`man git-up` or `git help up`:

    $ git up install-man

BANNER

    man_path = File.expand_path('../../man/git-up.1', __FILE__)

    case argv
    when []
      return
    when ["-v"], ["--version"]
      $stdout.puts "git-up #{GitUp::VERSION}"
      exit
    when ["man"]
      system "man", man_path
      exit
    when ["install-man"]
      destination = "/usr/local/share/man"
      print "Destination to install man page to [#{destination}]: "
      override = $stdin.gets.strip
      destination = override if override.length > 0

      dest_dir  = File.join(destination, "man1")
      dest_path = File.join(dest_dir, File.basename(man_path))

      exit(1) unless system "mkdir", "-p", dest_dir
      exit(1) unless system "cp", man_path, dest_path

      puts "Installed to #{dest_path}"

      exit
    when ["-h"], ["--help"]
      $stderr.puts(banner)
      exit
    when ["-no-f"], ["--no-fetch"]
      @fetch = false
    else
      $stderr.puts(banner)
      exit 1
    end
  end

  def rebase_all_branches
    col_width = branches.map { |b| b.name.length }.max + 1

    branches.each do |branch|
      remote = remote_map[branch.name]

      curbranch = branch.name.ljust(col_width)
      if branch.name == repo.head.name
        print curbranch.bold
      else
        print curbranch
      end

      if remote.commit.sha == branch.commit.sha
        puts "up to date".green
        next
      end

      base = merge_base(branch.name, remote.name)

      if base == remote.commit.sha
        puts "ahead of upstream".cyan
        next
      end

      if base == branch.commit.sha
        puts "fast-forwarding...".yellow
      elsif config("rebase.auto") == 'false'
        puts "diverged".red
        next
      else
        puts "rebasing...".yellow
      end

      log(branch, remote)
      checkout(branch.name)
      rebase(remote)
    end
  end

  def repo
    @repo ||= get_repo
  end

  def get_repo
    repo_dir = `git rev-parse --show-toplevel`.chomp

    if $? == 0
      Dir.chdir repo_dir
      @repo = Grit::Repo.new(repo_dir)
    else
      raise GitError, "We don't seem to be in a git repository."
    end
  end

  def branches
    @branches ||= repo.branches.select { |b| remote_map.has_key?(b.name) }.sort_by { |b| b.name }
  end

  def remotes
    @remotes ||= remote_map.values.map { |r| r.name.split('/', 2).first }.uniq
  end

  def remote_map
    @remote_map ||= repo.branches.inject({}) { |map, branch|
      if remote = remote_for_branch(branch)
        map[branch.name] = remote
      end

      map
    }
  end

  def remote_for_branch(branch)
    remote_name   = repo.config["branch.#{branch.name}.remote"] || "origin"
    remote_branch = repo.config["branch.#{branch.name}.merge"] || branch.name
    remote_branch.sub!(%r{^refs/heads/}, '')
    repo.remotes.find { |r| r.name == "#{remote_name}/#{remote_branch}" }
  end

  def with_stash
    stashed = false

    if change_count > 0
      puts "stashing #{change_count} changes".magenta
      repo.git.stash
      stashed = true
    end

    yield

    if stashed
      puts "unstashing".magenta
      repo.git.stash({}, "pop")
    end
  end

  def returning_to_current_branch
    unless repo.head.respond_to?(:name)
      puts "You're not currently on a branch. I'm exiting in case you're in the middle of something.".red
      return
    end

    branch_name = repo.head.name

    yield

    unless on_branch?(branch_name)
      puts "returning to #{branch_name}".magenta
      checkout(branch_name)
    end
  end

  def checkout(branch_name)
    output = repo.git.checkout({}, branch_name)

    unless on_branch?(branch_name)
      raise GitError.new("Failed to checkout #{branch_name}", output)
    end
  end

  def log(branch, remote)
    if log_hook = config("rebase.log-hook")
      system('sh', '-c', log_hook, 'git-up', branch.name, remote.name)
    end
  end

  def rebase(target_branch)
    current_branch = repo.head
    arguments = config("rebase.arguments")

    output, err = repo.git.sh("#{Grit::Git.git_binary} rebase #{arguments} #{target_branch.name}")

    unless on_branch?(current_branch.name) and is_fast_forward?(current_branch, target_branch)
      raise GitError.new("Failed to rebase #{current_branch.name} onto #{target_branch.name}", output+err)
    end
  end

  def check_bundler
    return unless use_bundler?

    begin
      require 'bundler'
      ENV['BUNDLE_GEMFILE'] ||= File.expand_path('Gemfile')
      Gem.loaded_specs.clear
      Bundler.setup
    rescue Bundler::GemNotFound, Bundler::GitError
      puts
      print 'Gems are missing. '.yellow

      if config("bundler.autoinstall") == 'true'
        puts "Running `bundle install`.".yellow
        system "bundle", "install"
      else
        puts "You should `bundle install`.".yellow
      end
    end
  end

  def is_fast_forward?(a, b)
    merge_base(a.name, b.name) == b.commit.sha
  end

  def merge_base(a, b)
    repo.git.send("merge-base", {}, a, b).strip
  end

  def on_branch?(branch_name=nil)
    repo.head.respond_to?(:name) and repo.head.name == branch_name
  end

  class GitError < StandardError
    def initialize(message, output=nil)
      @msg = "#{message.red}"

      if output
        @msg << "\n"
        @msg << "Here's what Git said:".red
        @msg << "\n"
        @msg << output
      end
    end

    def message
      @msg
    end
  end

private

  def use_bundler?
    use_bundler_config? and File.exists? 'Gemfile'
  end

  def use_bundler_config?
    if ENV.has_key?('GIT_UP_BUNDLER_CHECK')
      puts <<-EOS.yellow
The GIT_UP_BUNDLER_CHECK environment variable is deprecated.
You can now tell git-up to check (or not check) for missing
gems on a per-project basis using git's config system. To
set it globally, run this command anywhere:

    git config --global git-up.bundler.check true

To set it within a project, run this command inside that
project's directory:

    git config git-up.bundler.check true

Replace 'true' with 'false' to disable checking.
EOS
    end

    config("bundler.check") == 'true' || ENV['GIT_UP_BUNDLER_CHECK'] == 'true'
  end

  def prune?
    required_version = "1.6.6"
    config_value = config("fetch.prune")

    if git_version_at_least?(required_version)
      config_value != 'false'
    else
      if config_value == 'true'
        puts "Warning: fetch.prune is set to 'true' but your git version doesn't seem to support it (#{git_version} < #{required_version}). Defaulting to 'false'.".yellow
      end

      false
    end
  end

  def change_count
    @change_count ||= begin
      repo.git.status(:porcelain => true, :'untracked-files' => 'no').split("\n").count
    end
  end

  def config(key)
    repo.config["git-up.#{key}"]
  end

  def git_version_at_least?(required_version)
    (version_array(git_version) <=> version_array(required_version)) >= 0
  end

  def version_array(version_string)
    version_string.split('.').map { |s| s.to_i }
  end

  def git_version
    `git --version`[/\d+(\.\d+)+/]
  end
end