Merge pull request #262 from ghthor/refactor_of_git_status

Refactor of git status, Closes #132
This commit is contained in:
Wilhelmina Drengwitz
2018-08-31 16:11:14 -04:00
committed by GitHub
3 changed files with 514 additions and 185 deletions

478
lib/git/status_shortcuts.rb Normal file → Executable file
View File

@@ -6,213 +6,323 @@
# Released under the LGPL (GNU Lesser General Public License) # Released under the LGPL (GNU Lesser General Public License)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# #
# Original work by Nathan Broadbent
# Rewritten by LFDM
#
# A much faster implementation of git_status_shortcuts() in ruby # A much faster implementation of git_status_shortcuts() in ruby
# (bash: 0m0.549s, ruby: 0m0.045s) # (original benchmarks - bash: 0m0.549s, ruby: 0m0.045s, the updated
# version is twice as fast, especially noticable in big repos)
#
# #
# Last line of output contains the ordered absolute file paths, # Last line of output contains the ordered absolute file paths,
# which need to be extracted by the shell and exported as numbered env variables. # which need to be extracted by the shell and exported as numbered env variables.
# #
# Processes 'git status --porcelain', and exports numbered # Processes 'git status', and exports numbered
# env variables that contain the path of each affected file. # env variables that contain the path of each affected file.
# Output is also more concise than standard 'git status'. # Output is also more concise than standard 'git status'.
# #
# Call with optional <group> parameter to just show one modification state # Call with optional <group> parameter to just show one modification state
# # groups => 1: staged, 2: unmerged, 3: unstaged, 4: untracked # # groups => 1: staged, 2: unmerged, 3: unstaged, 4: untracked
# -------------------------------------------------------------------- # --------------------------------------------------------------------
#
require 'strscan'
@project_root = File.exist?(".git") ? Dir.pwd : `\git rev-parse --show-toplevel 2> /dev/null`.strip class GitStatus
def initialize(request = nil)
@request = request.to_s # capture nils
@status = get_status
@ahead, @behind = parse_remote_stat
@grouped_changes = parse_changes
@index = 0
end
@git_status = `\git status --porcelain -b 2> /dev/null` def report
exit if all_changes.length > ENV["gs_max_changes"].to_i
git_status_lines = @git_status.split("\n") print_header
git_branch = git_status_lines[0] print_groups
@branch = git_branch[/^## (?:Initial commit on )?([^ \.]+)/, 1] puts filelist if @grouped_changes.any?
@ahead = git_branch[/\[ahead ?(\d+).*\]/, 1]
@behind = git_branch[/\[.*behind ?(\d+)\]/, 1]
@changes = git_status_lines[1..-1]
# Exit if too many changes
exit if @changes.size > ENV["gs_max_changes"].to_i
# Colors
@c = {
:rst => "\033[0m",
:del => "\033[0;31m",
:mod => "\033[0;32m",
:new => "\033[0;33m",
:ren => "\033[0;34m",
:cpy => "\033[0;33m",
:typ => "\033[0;35m",
:unt => "\033[0;36m",
:dark => "\033[2;37m",
:branch => "\033[1m",
:header => "\033[0m"
}
# Following colors must be prepended with modifiers e.g. '\033[1;', '\033[0;'
@group_c = {
:staged => "33m",
:unmerged => "31m",
:unstaged => "32m",
:untracked => "36m"
}
@stat_hash = {
:staged => [],
:unmerged => [],
:unstaged => [],
:untracked => []
}
@output_files = []
# Counter for env variables
@e = 0
# Show how many commits we are ahead and/or behind origin
difference = ["-#{@behind}", "+#{@ahead}"].select{|d| d.length > 1}.join('/')
difference = difference.length > 0 ? " #{@c[:dark]}| #{@c[:new]}#{difference}#{@c[:rst]}" : ""
# If no changes, just display green no changes message and exit here
if @git_status == ""
puts "%s#%s On branch: %s#{@branch}#{difference} %s| \033[0;32mNo changes (working directory clean)%s" % [
@c[:dark], @c[:rst], @c[:branch], @c[:dark], @c[:rst]
]
exit
end
puts "%s#%s On branch: %s#{@branch}#{difference} %s| [%s*%s]%s => $#{ENV["git_env_char"]}*\n%s#%s" % [
@c[:dark], @c[:rst], @c[:branch], @c[:dark], @c[:rst], @c[:dark], @c[:rst], @c[:dark], @c[:rst]
]
def has_modules?
@has_modules ||= File.exists?(File.join(@project_root, '.gitmodules'))
end
# Index modification states
@changes.each do |change|
x, y, file = change[0, 1], change[1, 1], change[3..-1]
# Fetch the long git status once, but only if any submodules have changed
if not @git_status_long and has_modules?
@gitmodules ||= File.read(File.join(@project_root, '.gitmodules'))
# If changed 'file' is actually a git submodule
if @gitmodules.include?(file)
# Parse long git status for submodule summaries
@git_status_long = `git status`.gsub(/\033\[[^m]*m/, "") # (strip colors)
end
end end
msg, col, group = case change[0..1] ######### Parsing methods #########
when "DD"; [" both deleted", :del, :unmerged]
when "AU"; [" added by us", :new, :unmerged] def get_status
when "UD"; ["deleted by them", :del, :unmerged] `git status 2>/dev/null`
when "UA"; [" added by them", :new, :unmerged]
when "DU"; [" deleted by us", :del, :unmerged]
when "AA"; [" both added", :new, :unmerged]
when "UU"; [" both modified", :mod, :unmerged]
when /M./; [" modified", :mod, :staged]
when /A./; [" new file", :new, :staged]
when /D./; [" deleted", :del, :staged]
when /R./; [" renamed", :ren, :staged]
when /C./; [" copied", :cpy, :staged]
when /T./; ["typechange", :typ, :staged]
when "??"; [" untracked", :unt, :untracked]
end end
# Store data # Remote info is always on the second line of git status
@stat_hash[group] << {:msg => msg, :col => col, :file => file} if msg def parse_remote_stat
remote_line = @status.lines[1].strip
# Work tree modification states if remote_line.match(/diverged/)
if x == "R" && y == "M" remote_line.match(/.*(\d*).*(\d*)/).captures
# Extract the second file name from the format x -> y
quoted, unquoted = /^(?:"(?:[^"\\]|\\.)*"|[^"].*) -> (?:"((?:[^"\\]|\\.)*)"|(.*[^"]))$/.match(file)[1..2]
renamed_file = quoted || unquoted
@stat_hash[:unstaged] << {:msg => " modified", :col => :mod, :file => renamed_file}
elsif x != "R" && y == "M"
@stat_hash[:unstaged] << {:msg => " modified", :col => :mod, :file => file}
elsif y == "D" && x != "D" && x != "U"
# Don't show deleted 'y' during a merge conflict.
@stat_hash[:unstaged] << {:msg => " deleted", :col => :del, :file => file}
elsif y == "T"
@stat_hash[:unstaged] << {:msg => "typechange", :col => :typ, :file => file}
end
end
def relative_path(base, target)
back = ""
while target.sub(base,'') == target
base = base.sub(/\/[^\/]*$/, '')
back = "../#{back}"
end
"#{back}#{target.sub("#{base}/",'')}"
end
# Output files
def output_file_group(group)
# Print colored hashes & files based on modification groups
c_group = "\033[0;#{@group_c[group]}"
@stat_hash[group].each do |h|
@e += 1
padding = (@e < 10 && @changes.size >= 10) ? " " : ""
# Find relative path, i.e. ../../lib/path/to/file
rel_file = relative_path(Dir.pwd, File.join(@project_root, h[:file]))
# If some submodules have changed, parse their summaries from long git status
sub_stat = nil
if @git_status_long && (sub_stat = @git_status_long[/#{h[:file]} \((.*)\)/, 1])
# Format summary with parantheses
sub_stat = "(#{sub_stat})"
end
puts "#{c_group}##{@c[:rst]} #{@c[h[:col]]}#{h[:msg]}:\
#{padding}#{@c[:dark]} [#{@c[:rst]}#{@e}#{@c[:dark]}] #{c_group}#{rel_file}#{@c[:rst]} #{sub_stat}"
# Save the ordered list of output files
# fetch first file (in the case of oldFile -> newFile) and remove quotes
@output_files << if h[:msg] == "typechange"
# Only use relative paths for 'typechange' modifications.
"~#{rel_file}"
elsif h[:file] =~ /^"([^\\"]*(\\.[^"]*)*)"/
# Handle the regex above..
$1.gsub(/\\(.)/,'\1')
else else
# Else, strip file [remote_line[/is ahead of.*by (\d*).*/, 1], remote_line[/is behind.*by (\d*).*/, 1]]
h[:file].strip
end end
end end
puts "#{c_group}##{@c[:rst]}" # Extra '#' # We have to resort to the StringScanner to stay away from overly complex
end # regular expressions.
# The individual blocks are always formatted the same
#
[[:staged, 'Changes to be committed'], # identifier Changes not staged for commit
[:unmerged, 'Unmerged paths'], # helper text (use "git add <file>..." ...
[:unstaged, 'Changes not staged for commit'], # empty line
[:untracked, 'Untracked files'] # changed files, leaded by a tab modified: file
].each_with_index do |data, i| # deleted: other_file
group, heading = *data # empty line
# next identifier Untracked files
# Allow filtering by specific group (by string or integer) # ...
if !ARGV[0] || ARGV[0] == group.to_s || ARGV[0] == (i+1).to_s; then #
if !@stat_hash[group].empty? # We parse each requested group and return a grouped hash, its values are
c_arrow="\033[1;#{@group_c[group]}" # arrays of GitChange objects.
c_hash="\033[0;#{@group_c[group]}" def parse_changes
puts "#{c_arrow}#{@c[:header]} #{heading}\n#{c_hash}##{@c[:rst]}" scanner = StringScanner.new(@status)
output_file_group(group) requested_groups.each_with_object({}) do |(type, identifier), h|
if scanner.scan_until(/#{identifier}/)
scan_until_next_empty_line(scanner)
file_block = scan_until_next_empty_line(scanner)
h[type] = extract_changes(file_block)
end
scanner.reset
end end
end end
def scan_until_next_empty_line(scanner)
scanner.scan_until(/\n\n/)
end
# Matches
# modified: file # usual output in git status
# modified: file (untracked content) # output for submodules
# file # untracked files have no additional info
def extract_changes(str)
str.lines.map do |line|
new_git_change(*Regexp.last_match.captures) if line.match(/\t(.*:)?(.*)/)
end.compact # in case there were any non matching lines left
end
def new_git_change(status, file_and_message)
status = 'untracked:' unless status
GitChange.new(file_and_message, status)
end
GROUPS = {
staged: 'Changes to be committed',
unmerged: 'Unmerged paths',
unstaged: 'Changes not staged for commit',
untracked: 'Untracked files'
}
# Returns all possible groups when there was no request at all,
# otherwise selects groups by name or integer
def requested_groups
@request.empty? ? GROUPS : select_groups
end
def select_groups
req = parse_request
GROUPS.select { |k, _| k == req }
end
def parse_request
if @request.match(/\d/)
GROUPS.keys[@request.to_i - 1]
else
@request.to_sym
end
end
######### Outputting methods #########
def print_header
puts delimiter(:header) + header
puts delimiter(:header) if anything_changed?
end
def print_groups
@grouped_changes.each do |type, changes|
print_group_header(type)
puts delimiter(type)
changes.each do |change|
raise_index!
print delimiter(type)
puts change.report_with_index(@index, type, padding)
end
puts delimiter(type)
end
end
def print_group_header(type)
puts "#{gmu('➤', type, 1)} #{GROUPS[type]}"
end
######### Items of interest #########
def branch
@status.lines.first.strip[/^On branch (.*)$/, 1]
end
def ahead
"+#{@ahead}" if @ahead
end
def behind
"-#{@behind}" if @behind
end
def difference
[behind, ahead].compact.join('/')
end
def header
parts = [[:branch, :branch], [:difference, :new]]
parts << (anything_changed? ? [:hotkey, :dark] : [:clean_state, :mod]) # mod is green
# compact because difference might return nil
"On branch: #{parts.map { |meth, col| mu(send(meth), col) }.compact.join(' | ')}"
end
def clean_state
"No changes (working directory clean)"
end
def hotkey
"[*] => $#{ENV['git_env_char']}"
end
# used to delimit the left side of the screen - looks nice
def delimiter(col)
gmu("# ", col)
end
def filelist
"@@filelist@@::#{all_changes.map(&:absolute_path).join('|')}"
end
######### Helper Methods #########
# To know about changes we could ask if there are any parsing results, as in
# @grouped_changes.any?, but that is not a good idea, since
# we might have selected a requested group before parsing already.
# Groups could be empty while there are in fact changes present,
# there we look into the original status string once
def anything_changed?
@any_changes ||=
! @status.match(/nothing to commit.*working directory clean/)
end
# needed by hotkey filelist
def raise_index!
@index += 1
end
def all_changes
@all_changes ||= @grouped_changes.values.flatten
end
# Dynamic padding, always looks for the longest status string present
# and adds a little whitespace
def padding
@padding ||= all_changes.map { |change| change.status.size }.max + 5
end
######### Markup/Color methods #########
COL = {
:rst => "0",
:header => "0",
:branch => "1",
:del => "0;31",
:mod => "0;32",
:new => "0;33",
:ren => "0;34",
:cpy => "0;33",
:typ => "0;35",
:unt => "0;36",
:dark => "2;37",
}
GR_COL = {
:staged => "33",
:unmerged => "31",
:unstaged => "32",
:untracked => "36",
}
# markup
def mu(str, col_in, col_out = :rst)
return if str.empty?
col_in = "\033[#{COL[col_in]}m"
col_out = "\033[#{COL[col_out]}m"
with_color(str, col_in, col_out)
end
# group markup
def gmu(str, group, boldness = 0, col_out = :rst)
group_col = "\033[#{boldness};#{GR_COL[group]}m"
col_out = "\033[#{COL[col_out]}m"
with_color(str, group_col, col_out)
end
def with_color(str, col_in, col_out)
"#{col_in}#{str}#{col_out}"
end
end end
print "@@filelist@@::" class GitChange < GitStatus
puts @output_files.map {|f| attr_reader :status
# If file starts with a '~', treat it as a relative path.
# This is important when dealing with symlinks # Restructively singles out the submodules message and
f.start_with?("~") ? f.sub(/~/, '') : File.join(@project_root, f) # strips the remaining string to get rid of padding
}.join("|") def initialize(file_and_message, status)
@message = file_and_message.slice!(/\(.*\)/)
@file = file_and_message.strip
@file = (@file.include? " ") ? "\"#{@file}\"" : @file
@status = status.strip
end
def absolute_path
File.expand_path(@file, Dir.pwd)
end
STATUS_COLORS = {
"copied" => :cpy,
"both deleted" => :del,
"deleted by us" => :del,
"deleted by them" => :del,
"deleted" => :del,
"both modified" => :mod,
"modified" => :mod,
"added by them" => :new,
"added by us" => :new,
"both added" => :new,
"new file" => :new,
"renamed" => :ren,
"typechange" => :typ,
"untracked" => :unt,
}
# Looks like this
#
# PADDING STATUS INDEX FILE MESSAGE (optional)
# modified: [1] changed_file (untracked content)
#
def report_with_index(index, type, padding = 0)
"#{pad(padding)}#{mu(@status, color_key)} " +
"#{mu("[#{index}]", :dark)} #{gmu(@file, type)} #{@message}"
end
# we most likely have a : with us which we don't need here
def color_key
STATUS_COLORS[@status.chomp(':')]
end
def pad(padding)
' ' * (padding - @status.size)
end
end
GitStatus.new(ARGV.first).report

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env ruby
# encoding: UTF-8
# ------------------------------------------------------------------------------
# SCM Breeze - Streamline your SCM workflow.
# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved.
# Released under the LGPL (GNU Lesser General Public License)
# ------------------------------------------------------------------------------
#
# A much faster implementation of git_status_shortcuts() in ruby
# (bash: 0m0.549s, ruby: 0m0.045s)
#
# Last line of output contains the ordered absolute file paths,
# which need to be extracted by the shell and exported as numbered env variables.
#
# Processes 'git status --porcelain', and exports numbered
# env variables that contain the path of each affected file.
# Output is also more concise than standard 'git status'.
#
# Call with optional <group> parameter to just show one modification state
# # groups => 1: staged, 2: unmerged, 3: unstaged, 4: untracked
# --------------------------------------------------------------------
@project_root = File.exist?(".git") ? Dir.pwd : `\git rev-parse --show-toplevel 2> /dev/null`.strip
@git_status = `\git status --porcelain -b 2> /dev/null`
git_status_lines = @git_status.split("\n")
git_branch = git_status_lines[0]
@branch = git_branch[/^## (?:Initial commit on )?([^ \.]+)/, 1]
@ahead = git_branch[/\[ahead ?(\d+).*\]/, 1]
@behind = git_branch[/\[.*behind ?(\d+)\]/, 1]
@changes = git_status_lines[1..-1]
# Exit if too many changes
exit if @changes.size > ENV["gs_max_changes"].to_i
# Colors
@c = {
:rst => "\033[0m",
:del => "\033[0;31m",
:mod => "\033[0;32m",
:new => "\033[0;33m",
:ren => "\033[0;34m",
:cpy => "\033[0;33m",
:typ => "\033[0;35m",
:unt => "\033[0;36m",
:dark => "\033[2;37m",
:branch => "\033[1m",
:header => "\033[0m"
}
# Following colors must be prepended with modifiers e.g. '\033[1;', '\033[0;'
@group_c = {
:staged => "33m",
:unmerged => "31m",
:unstaged => "32m",
:untracked => "36m"
}
@stat_hash = {
:staged => [],
:unmerged => [],
:unstaged => [],
:untracked => []
}
@output_files = []
# Counter for env variables
@e = 0
# Show how many commits we are ahead and/or behind origin
difference = ["-#{@behind}", "+#{@ahead}"].select{|d| d.length > 1}.join('/')
difference = difference.length > 0 ? " #{@c[:dark]}| #{@c[:new]}#{difference}#{@c[:rst]}" : ""
# If no changes, just display green no changes message and exit here
if @git_status == ""
puts "%s#%s On branch: %s#{@branch}#{difference} %s| \033[0;32mNo changes (working directory clean)%s" % [
@c[:dark], @c[:rst], @c[:branch], @c[:dark], @c[:rst]
]
exit
end
puts "%s#%s On branch: %s#{@branch}#{difference} %s| [%s*%s]%s => $#{ENV["git_env_char"]}*\n%s#%s" % [
@c[:dark], @c[:rst], @c[:branch], @c[:dark], @c[:rst], @c[:dark], @c[:rst], @c[:dark], @c[:rst]
]
def has_modules?
@has_modules ||= File.exists?(File.join(@project_root, '.gitmodules'))
end
# Index modification states
@changes.each do |change|
x, y, file = change[0, 1], change[1, 1], change[3..-1]
# Fetch the long git status once, but only if any submodules have changed
if not @git_status_long and has_modules?
@gitmodules ||= File.read(File.join(@project_root, '.gitmodules'))
# If changed 'file' is actually a git submodule
if @gitmodules.include?(file)
# Parse long git status for submodule summaries
@git_status_long = `git status`.gsub(/\033\[[^m]*m/, "") # (strip colors)
end
end
msg, col, group = case change[0..1]
when "DD"; [" both deleted", :del, :unmerged]
when "AU"; [" added by us", :new, :unmerged]
when "UD"; ["deleted by them", :del, :unmerged]
when "UA"; [" added by them", :new, :unmerged]
when "DU"; [" deleted by us", :del, :unmerged]
when "AA"; [" both added", :new, :unmerged]
when "UU"; [" both modified", :mod, :unmerged]
when /M./; [" modified", :mod, :staged]
when /A./; [" new file", :new, :staged]
when /D./; [" deleted", :del, :staged]
when /R./; [" renamed", :ren, :staged]
when /C./; [" copied", :cpy, :staged]
when /T./; ["typechange", :typ, :staged]
when "??"; [" untracked", :unt, :untracked]
end
# Store data
@stat_hash[group] << {:msg => msg, :col => col, :file => file} if msg
# Work tree modification states
if x == "R" && y == "M"
# Extract the second file name from the format x -> y
quoted, unquoted = /^(?:"(?:[^"\\]|\\.)*"|[^"].*) -> (?:"((?:[^"\\]|\\.)*)"|(.*[^"]))$/.match(file)[1..2]
renamed_file = quoted || unquoted
@stat_hash[:unstaged] << {:msg => " modified", :col => :mod, :file => renamed_file}
elsif x != "R" && y == "M"
@stat_hash[:unstaged] << {:msg => " modified", :col => :mod, :file => file}
elsif y == "D" && x != "D" && x != "U"
# Don't show deleted 'y' during a merge conflict.
@stat_hash[:unstaged] << {:msg => " deleted", :col => :del, :file => file}
elsif y == "T"
@stat_hash[:unstaged] << {:msg => "typechange", :col => :typ, :file => file}
end
end
def relative_path(base, target)
back = ""
while target.sub(base,'') == target
base = base.sub(/\/[^\/]*$/, '')
back = "../#{back}"
end
"#{back}#{target.sub("#{base}/",'')}"
end
# Output files
def output_file_group(group)
# Print colored hashes & files based on modification groups
c_group = "\033[0;#{@group_c[group]}"
@stat_hash[group].each do |h|
@e += 1
padding = (@e < 10 && @changes.size >= 10) ? " " : ""
# Find relative path, i.e. ../../lib/path/to/file
rel_file = relative_path(Dir.pwd, File.join(@project_root, h[:file]))
# If some submodules have changed, parse their summaries from long git status
sub_stat = nil
if @git_status_long && (sub_stat = @git_status_long[/#{h[:file]} \((.*)\)/, 1])
# Format summary with parantheses
sub_stat = "(#{sub_stat})"
end
puts "#{c_group}##{@c[:rst]} #{@c[h[:col]]}#{h[:msg]}:\
#{padding}#{@c[:dark]} [#{@c[:rst]}#{@e}#{@c[:dark]}] #{c_group}#{rel_file}#{@c[:rst]} #{sub_stat}"
# Save the ordered list of output files
# fetch first file (in the case of oldFile -> newFile) and remove quotes
@output_files << if h[:msg] == "typechange"
# Only use relative paths for 'typechange' modifications.
"~#{rel_file}"
elsif h[:file] =~ /^"([^\\"]*(\\.[^"]*)*)"/
# Handle the regex above..
$1.gsub(/\\(.)/,'\1')
else
# Else, strip file
h[:file].strip
end
end
puts "#{c_group}##{@c[:rst]}" # Extra '#'
end
[[:staged, 'Changes to be committed'],
[:unmerged, 'Unmerged paths'],
[:unstaged, 'Changes not staged for commit'],
[:untracked, 'Untracked files']
].each_with_index do |data, i|
group, heading = *data
# Allow filtering by specific group (by string or integer)
if !ARGV[0] || ARGV[0] == group.to_s || ARGV[0] == (i+1).to_s; then
if !@stat_hash[group].empty?
c_arrow="\033[1;#{@group_c[group]}"
c_hash="\033[0;#{@group_c[group]}"
puts "#{c_arrow}➤#{@c[:header]} #{heading}\n#{c_hash}##{@c[:rst]}"
output_file_group(group)
end
end
end
print "@@filelist@@::"
puts @output_files.map {|f|
# If file starts with a '~', treat it as a relative path.
# This is important when dealing with symlinks
f.start_with?("~") ? f.sub(/~/, '') : File.join(@project_root, f)
}.join("|")

View File

@@ -15,7 +15,8 @@ fi
# Strip color codes from a string # Strip color codes from a string
strip_colors() { strip_colors() {
perl -pe 's/\e\[[\d;]*m//g' # Updated with info from: https://superuser.com/a/380778
perl -pe 's/\x1b\[[0-9;]*[mG]//g'
} }
# Print space separated tab completion options # Print space separated tab completion options