unixorn/git-extra-commands

View on GitHub
bin/git-sr

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby
#
# Copyright 2020 Noel Cower
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the
#    distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#
# Usage: git-sr [options] [query]
# Switch to a different git branch or ref (branch or tag right now) using fzf to
# pick the branch.

require 'set'
require 'optparse'

COMMAND = 'git-sr'

# Whether to disable the preview window. (git-sr.disablePreview)
DEFAULT_DISABLE_PREVIEW = false
# Default log format. Not configurable.
DEFAULT_PREVIEW_LOG_FORMAT = %q[format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %s - %an%C(reset)%C(bold yellow)%d%C(reset)']
# Default preview command for fzf. (git-sr.preview)
DEFAULT_PREVIEW = %Q[git log --first-parent --format=#{DEFAULT_PREVIEW_LOG_FORMAT} --stat --color {}]
# Preview window layout for fzf. (git-sr.previewWindow)
DEFAULT_PREVIEW_WINDOW = 'up:80%'
# Whether to automatically select the only matching value given the default
# query. (git-sr.selectOne)
DEFAULT_SELECT_ONE = true
# Whether to show local heads. (git-sr.pickLocal)
DEFAULT_PICK_LOCAL = true
# Whether to show remote heads. (git-sr.pickRemote)
DEFAULT_PICK_REMOTE = false
# Whether to show all remote heads, including those with equivalent local heads.
# (git-sr.pickAllRemote)
DEFAULT_PICK_ALL_REMOTE = false
# Whether to show tags. (git-sr.pickTags)
DEFAULT_PICK_TAGS = false
# Whether to show all refs. (git-sr.pickAll)
DEFAULT_PICK_ALL = false

Filter = Struct.new(:regexp, :keep)

# BOOL_TRUE is a simple regular expression for saying whether something is
# true-ish.
BOOL_TRUE = /^(?:t(?:rue)?|\+?[1-9]\d*|yes|on)$/i

# to_b converts an arbitrary value to a boolean.
def to_b(v)
  case v
  when true, false then v
  when Numeric
    v != 0
  when String
    BOOL_TRUE.match?(v)
  else
    !v.nil?
  end
end

# config looks up a git-config key and returns the value (or all values if
# all:true is passed). If no value or an empty value is found, default is
# returned. If all is set and default is nil, then an empty Array is returned.
def config(key, default: nil, all: false)
  if all
    v = IO.popen([*%w[git config --null --get-all], key]) { |io| io.read }
    v.chomp("\x00")
    return (default || []) if v.empty?
    return v.split("\x00")
  end
  v = IO.popen([*%w[git config --null --get-all], key]) { |io| io.read }
  v.chomp("\x00")
  v = v.chomp("\x00").split("\x00", 2).first
  return default if v.nil?
  v
end

# names is a convenience function for parsing a sequence of NUL-separated names
# and applying Filters to them before returning the names as a Set.
def names(v, filters = [])
  ary = v.split("\x00").reject { |it| it.empty? || it == "\n" }
  filters = filters.group_by(&:keep)
  selects = filters[true]
  rejects = filters[false]
  if selects
    ary.select! do |it|
      selects.any? { |f| f.regexp.match?(it) }
    end
  end
  if rejects
    ary.reject! do |it|
      rejects.any? { |f| f.regexp.match?(it) }
    end
  end
  Set.new(ary)
end

# each_ref is a convenience function for calling git-for-each-ref and returning
# the results as a Set of names.
def each_ref(*paths, format: '%(refname:short)', filters: [])
  names(
    IO.popen([*%w[git for-each-ref], "--format=%00#{format}%00", *paths], 'r') { |io| io.read },
    filters
  )
end

# default_include is the default set of include filters taken from git-config.
default_include = config("#{COMMAND}.exclude", all: true).map do |it|
  Filter[Regexp.new(it), false]
end

# default_exclude is the default set of exclude filters taken from git-config.
default_exclude = config("#{COMMAND}.include", all: true).map do |it|
  Filter[Regexp.new(it), true]
end

# filters is the set of default and CLI-provided filters.
filters = [*default_include, *default_exclude]

# Whether to display local heads.
pick_locals = to_b config("#{COMMAND}.pickLocal", default: DEFAULT_PICK_LOCAL)

# Whether to display remote heads.
pick_remotes = to_b config("#{COMMAND}.pickRemote", default: DEFAULT_PICK_REMOTE)
# Whether to display all remote heads, including those that have equivalent
# local heads.
pick_all_remotes = to_b config("#{COMMAND}.pickAllRemote", default: DEFAULT_PICK_ALL_REMOTE)
if pick_all_remotes then
  pick_remotes = true
end

# Whether to display tags.
pick_tags = to_b config("#{COMMAND}.pickTags", default: DEFAULT_PICK_TAGS)

# If the pickAll config is set, then make all of the above pick_ variables true.
if to_b config("#{COMMAND}.pickAll", default: DEFAULT_PICK_ALL)
  pick_locals = true
  pick_remotes = true
  pick_all_remotes = true
  pick_tags = true
end

# Remote names to omit or only show refs from. If onlyRemote is set, it filters
# the set of remotes after omit_remotes are filtered out.
omit_remotes = config("#{COMMAND}.excludeRemote", all: true)
only_remotes = config("#{COMMAND}.onlyRemote", all: true)

# Parse arguments.
OptionParser.new do |opts|
  opts.banner = "Usage: #{COMMAND.sub(/^git-/, 'git ')} [options] [query]"

  opts.on("-a", "--all-refs", "Shorthand for -mrt") do |v|
    pick_remotes = v
    pick_all_remotes = v
    pick_tags = v
  end
  opts.on("-l", "--[no-]local", "Pick local refs") do |v|
    pick_locals = v
  end
  opts.on("-L", "--no-local", "Ignore local refs") do |v|
    pick_locals = v
  end
  opts.on("-r", "--[no-]remote", "Select remote heads") do |v|
    pick_remotes = v
  end
  opts.on("-R", "--no-remote", "Do not select remote heads") do |v|
    pick_remotes = v
  end
  opts.on("-m", "--[no-]all-remote", "Select all remote heads") do |v|
    pick_remotes = true if v
    pick_all_remotes = v
  end
  opts.on("-M", "--no-all-remote", "Do not select all remote heads") do |v|
    pick_all_remotes = v
  end
  opts.on("-t", "--[no-]tags", "Select tags") do |v|
    pick_tags = v
  end
  opts.on("-T", "--no-tags", "Do not select tags") do |v|
    pick_tags = v
  end
  opts.on("-IREMOTE", "--ignore-remote=REMOTE", "Ignore refs from a remote") do |v|
    omit_remotes.push v
  end
  opts.on("-oREMOTE", "--only-remote=REMOTE", "Only list remote refs from a remote") do |v|
    only_remotes.push v
  end
  opts.on("-iREGEXP", "--include=REGEXP", "Include refs matching a regexp") do |v|
    filters.push Filter[Regexp.new(v), true]
  end
  opts.on("-eREGEXP", "--exclude=REGEXP", "Exclude refs matching a regexp") do |v|
    filters.push Filter[Regexp.new(v), false]
  end
end.parse!

# Convert remote filters to sets.
omit_remotes = Set.new(omit_remotes)
only_remotes = Set.new(only_remotes)

# Get a set of all remote names and filter them using omit_remotes and
# only_remotes.
remotes=names(%x{
  git config --name-only --null --get-regexp '^remote\..+\.url$'
}, []).map { |it| it.delete_prefix("remote.").delete_suffix(".url") }
remotes.reject! { |it| omit_remotes.include? it }
unless only_remotes.empty?
  remotes.select! { |it| only_remotes.include?(it) }
end

# Get all local heads.
local_refs = Set.new
if pick_locals
  local_refs = each_ref('refs/heads', filters: filters)
end

# Get all remote heads.
remote_refs = Set.new
if pick_remotes
  remote_refs = each_ref(
    *(remotes.map { |it| "refs/remotes/#{it}/" }),
    format: '%(refname:lstrip=2)',
    filters: filters
  )
end

# Unless pick_all_remotes is set, filter out any remote refs that line up with
# a local ref.
unless pick_all_remotes
  local_refs.each do |it|
    remotes.each do |r|
      remote_refs.delete "#{r}/#{it}"
    end
  end
end

# Get all tag refs.
tag_refs = Set.new()
if pick_tags
  tag_refs = each_ref(
    'refs/tags',
    format: '%(refname:lstrip=1)',
    filters: filters
  )
end

# Combine all ref names together in the order local > remote > tags.
refs=[*local_refs, *remote_refs, *tag_refs]

if refs.empty?
  $stderr.puts "No refs found."
  exit 1
end

optargs = []

# If selectOne is true, then allow selecting the only matching ref.
if to_b config("#{COMMAND}.selectOne", default: DEFAULT_SELECT_ONE)
  optargs.push '--select-1'
end

# Grab preview window config.
unless to_b config("#{COMMAND}.disablePreview", default: DEFAULT_DISABLE_PREVIEW)
  preview_window = config("#{COMMAND}.previewWindow", default: DEFAULT_PREVIEW_WINDOW)
  preview_command = config("#{COMMAND}.preview", default: DEFAULT_PREVIEW)
  optargs.push(
    "--preview=#{preview_command}",
    "--preview-window=#{preview_window}"
  )
end

# Pass all ref names to fzf to select one.
sel = IO.popen(%W[fzf
    --filepath-word
    --read0
    --print0
    --query=#{ARGV.join ' '}
] + optargs, 'r+') do |io|
  io.write(refs.join("\x00"))
  io.close_write
  io.read
end

# Grab the first ref emitted and exec to git checkout to change refs.
ref = sel.split("\x00").first
exit 1 if ref.nil? || ref.empty?

case
when remote_refs.include?(ref)
  remote, ref = ref.split("/", 2)
  # Check out the remote ref as a detached head if there's already a branch by
  # the same name.
  exec(*%w[git checkout], "refs/remotes/#{remote}/#{ref}") if local_refs.include?(ref)
  # Check out the remote ref as a new branch.
  exec(*%w[git checkout --track --branch], ref, "refs/remotes/#{remote}/#{ref}")
when tag_refs.include?(ref)
  exec(*%w[git checkout], "refs/tags/#{ref}")
else
  exec(*%w[git checkout], ref)
end