From 33b561ebc1785b6c324c310468d7203dca0d70f3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Wed, 29 Jan 2014 21:05:24 +0100 Subject: [PATCH 1/6] status_shortcuts.rb completely rewritten --- lib/git/status_shortcuts.rb | 421 ++++++++++++++++++++---------------- 1 file changed, 229 insertions(+), 192 deletions(-) mode change 100644 => 100755 lib/git/status_shortcuts.rb diff --git a/lib/git/status_shortcuts.rb b/lib/git/status_shortcuts.rb old mode 100644 new mode 100755 index d5c8886..71eeacf --- a/lib/git/status_shortcuts.rb +++ b/lib/git/status_shortcuts.rb @@ -1,212 +1,249 @@ #!/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 parameter to just show one modification state -# # 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 -@git_status = `\git status --porcelain 2> /dev/null` + COL = { + :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" + } -git_branch = `\git branch -v 2> /dev/null` -@branch = git_branch[/^\* (\(no branch\)|[^ ]*)/, 1] -@ahead = git_branch[/^\* [^ ]* *[^ ]* *\[ahead ?(\d+).*\]/, 1] -@behind = git_branch[/^\* [^ ]* *[^ ]* *\[.*behind ?(\d+)\]/, 1] + # following colors must be prepended with modifiers e.g. '\033[1;', '\033[0;' + GR_COL = { + :header => "\033[2;37m", + :staged => "33m", + :unmerged => "31m", + :unstaged => "32m", + :untracked => "36m" + } -@changes = @git_status.split("\n") -# Exit if too many changes -exit if @changes.size > ENV["gs_max_changes"].to_i + GROUPS = { + staged: 'Changes to be committed', + unmerged: 'Unmerged paths', + unstaged: 'Changes not staged for commit', + untracked: 'Untracked files' + } -# 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 + def initialize(request = nil) + @request = request + @status = get_status + @ahead, @behind = parse_remote_stat + @grouped_changes = parse_changes + @index = 0 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] + def raise_index! + @index += 1 end - # Store data - @stat_hash[group] << {:msg => msg, :col => col, :file => file} if msg - - # Work tree modification states - if 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} + def branch + @status.lines.first.strip[/^On branch (.*)$/, 1] 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') + def parse_remote_stat + remote_line = @status.lines[1].strip + if remote_line.match(/diverged/) + remote_line.match(/.*(\d*).*(\d*)/).captures else - # Else, strip file - h[:file].strip + [remote_line[/is ahead of.*by (\d*).*/, 1], remote_line[/is behind.*by (\d*).*/, 1]] 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) + def parse_changes + scanner = StringScanner.new(@status) + 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 + + def scan_until_next_empty_line(scanner) + scanner.scan_until(/\n\n/) + end + + 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 + + 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 + + def report + print_header + print_groups + puts filelist if anything_changed? + end + + def print_header + puts delimiter(:header) + header + puts delimiter(:header) if anything_changed? + end + + def filelist + "@@filelist@@::#{all_changes.map(&:absolute_path).join('|')}" + 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 + + def all_changes + @all_changes ||= @grouped_changes.values.flatten + end + + def padding + @padding ||= all_changes.map { |change| change.status.size }.max + 5 + end + + def ahead + "+#{@ahead}" if @ahead + end + + def behind + "-#{@behind}" if @behind + end + + def difference + [behind, ahead].compact.join('/') + end + + # markup + def mu(str, col_in, col_out = :rst) + return if str.empty? + "#{COL[col_in]}#{str}#{COL[col_out]}" + end + + # group markup + def gmu(str, group, boldness = 0, col_out = :rst) + "\033[#{boldness};#{GR_COL[group]}#{str}#{COL[:rst]}" + 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 anything_changed? + @grouped_changes.any? + end + + def branch_difference_and_hotkey + [mu(branch, :branch), mu(difference, :new), mu(hotkey, :dark)].compact.join(' | ') + end + + def clean_state + "No changes (working directory clean)" + end + + def hotkey + "[*] => $#{ENV['git_env_char']}" + end + + def delimiter(col) + gmu("# ", col) + end + + def gc(color) + end + + def get_status + `git status 2>/dev/null` + 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("|") +class GitChange < GitStatus + attr_reader :status + def initialize(file_and_message, status) + # destructively cut out the submodules message + # strip the remaining for to get rid of padding + @message = file_and_message.slice!(/\(.*\)/) + @file = file_and_message.strip + @status = status.strip + end + + STATUS_COLORS = { + "both deleted" => :del, + "added by us" => :new, + "deleted by them" => :del, + "added by them" => :new, + "deleted by us" => :del, + "both added" => :new, + "both modified" => :mod, + "modified" => :mod, + "new file" => :new, + "deleted" => :del, + "renamed" => :ren, + "copied" => :cpy, + "typechange" => :typ, + "untracked" => :unt, + } + + def color_key + # we most likely have a : with us + STATUS_COLORS[@status.chomp(':')] + end + + def report_with_index(index, type, padding = 0) + "#{pad(padding)}#{mu(@status, color_key)} " + + "#{mu("[#{index}]", :dark)} #{gmu(@file, type)} #{@message}" + end + + def pad(padding) + ' ' * (padding - @status.size) + end + + def absolute_path + File.expand_path(@file, Dir.pwd) + end +end + +GitStatus.new('').report From fe1d4435a7fa3333ca7198f8638d8b788a4d9421 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Wed, 29 Jan 2014 22:53:20 +0100 Subject: [PATCH 2/6] Refactor, commenting, polishing. --- lib/git/status_shortcuts.rb | 259 +++++++++++++++++++++++------------- 1 file changed, 168 insertions(+), 91 deletions(-) diff --git a/lib/git/status_shortcuts.rb b/lib/git/status_shortcuts.rb index 71eeacf..f76f1a5 100755 --- a/lib/git/status_shortcuts.rb +++ b/lib/git/status_shortcuts.rb @@ -1,54 +1,55 @@ #!/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) +# ------------------------------------------------------------------------------ +# +# Original work by Nathan Broadbent +# Rewritten by LFDM +# +# A much faster implementation of git_status_shortcuts() in ruby +# (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, +# which need to be extracted by the shell and exported as numbered env variables. +# +# Processes 'git status', 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 parameter to just show one modification state +# # groups => 1: staged, 2: unmerged, 3: unstaged, 4: untracked +# -------------------------------------------------------------------- +# require 'strscan' class GitStatus - - COL = { - :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;' - GR_COL = { - :header => "\033[2;37m", - :staged => "33m", - :unmerged => "31m", - :unstaged => "32m", - :untracked => "36m" - } - - GROUPS = { - staged: 'Changes to be committed', - unmerged: 'Unmerged paths', - unstaged: 'Changes not staged for commit', - untracked: 'Untracked files' - } - def initialize(request = nil) - @request = request + @request = request.to_s # capture nils @status = get_status @ahead, @behind = parse_remote_stat @grouped_changes = parse_changes @index = 0 end - def raise_index! - @index += 1 + def report + print_header + print_groups + puts filelist if @grouped_changes.any? end - def branch - @status.lines.first.strip[/^On branch (.*)$/, 1] + + ######### Parsing methods ######### + + def get_status + `git status 2>/dev/null` end + # Remote info is always on the second line of git status def parse_remote_stat remote_line = @status.lines[1].strip if remote_line.match(/diverged/) @@ -58,6 +59,21 @@ class GitStatus end end + # We have to resort to the StringScanner to stay away from overly complex + # regular expressions. + # The individual blocks are always formatted the same + # + # identifier Changes not staged for commit + # helper text (use "git add ..." ... + # empty line + # changed files, leaded by a tab modified: file + # deleted: other_file + # empty line + # next identifier Untracked files + # ... + # + # We parse each requested group and return a grouped hash, its values are + # arrays of GitChange objects. def parse_changes scanner = StringScanner.new(@status) requested_groups.each_with_object({}) do |(type, identifier), h| @@ -74,6 +90,10 @@ class GitStatus 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(.*:)?(.*)/) @@ -81,10 +101,19 @@ class GitStatus end def new_git_change(status, file_and_message) - status = 'untracked' unless status + 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 @@ -102,21 +131,14 @@ class GitStatus end end - def report - print_header - print_groups - puts filelist if anything_changed? - end + + ######### Outputting methods ######### def print_header puts delimiter(:header) + header puts delimiter(:header) if anything_changed? end - def filelist - "@@filelist@@::#{all_changes.map(&:absolute_path).join('|')}" - end - def print_groups @grouped_changes.each do |type, changes| print_group_header(type) @@ -134,12 +156,11 @@ class GitStatus puts "#{gmu('➤', type, 1)} #{GROUPS[type]}" end - def all_changes - @all_changes ||= @grouped_changes.values.flatten - end - def padding - @padding ||= all_changes.map { |change| change.status.size }.max + 5 + ######### Items of interest ######### + + def branch + @status.lines.first.strip[/^On branch (.*)$/, 1] end def ahead @@ -154,17 +175,6 @@ class GitStatus [behind, ahead].compact.join('/') end - # markup - def mu(str, col_in, col_out = :rst) - return if str.empty? - "#{COL[col_in]}#{str}#{COL[col_out]}" - end - - # group markup - def gmu(str, group, boldness = 0, col_out = :rst) - "\033[#{boldness};#{GR_COL[group]}#{str}#{COL[:rst]}" - end - def header parts = [[:branch, :branch], [:difference, :new]] parts << (anything_changed? ? [:hotkey, :dark] : [:clean_state, :mod]) # mod is green @@ -172,14 +182,6 @@ class GitStatus "On branch: #{parts.map { |meth, col| mu(send(meth), col) }.compact.join(' | ')}" end - def anything_changed? - @grouped_changes.any? - end - - def branch_difference_and_hotkey - [mu(branch, :branch), mu(difference, :new), mu(hotkey, :dark)].compact.join(' | ') - end - def clean_state "No changes (working directory clean)" end @@ -188,62 +190,137 @@ class GitStatus "[*] => $#{ENV['git_env_char']}" end + # used to delimit the left side of the screen - looks nice def delimiter(col) gmu("# ", col) end - def gc(color) + def filelist + "@@filelist@@::#{all_changes.map(&:absolute_path).join('|')}" end - def get_status - `git status 2>/dev/null` + + ######### 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 class GitChange < GitStatus attr_reader :status + + # Restructively singles out the submodules message and + # strips the remaining string to get rid of padding def initialize(file_and_message, status) - # destructively cut out the submodules message - # strip the remaining for to get rid of padding @message = file_and_message.slice!(/\(.*\)/) @file = file_and_message.strip @status = status.strip end + def absolute_path + File.expand_path(@file, Dir.pwd) + end + STATUS_COLORS = { + "copied" => :cpy, "both deleted" => :del, - "added by us" => :new, - "deleted by them" => :del, - "added by them" => :new, "deleted by us" => :del, - "both added" => :new, + "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, - "deleted" => :del, "renamed" => :ren, - "copied" => :cpy, "typechange" => :typ, "untracked" => :unt, } - def color_key - # we most likely have a : with us - STATUS_COLORS[@status.chomp(':')] - end - + # 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 - - def absolute_path - File.expand_path(@file, Dir.pwd) - end end -GitStatus.new('').report +GitStatus.new(ARGV.first).report From 0acc0223a5be26065c61c52bd15b674bf9f49ea7 Mon Sep 17 00:00:00 2001 From: Willa Drengwitz Date: Fri, 31 Aug 2018 13:22:16 -0400 Subject: [PATCH 3/6] Add legacy status script --- lib/git/status_shortcuts.rb.legacy | 218 +++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 lib/git/status_shortcuts.rb.legacy diff --git a/lib/git/status_shortcuts.rb.legacy b/lib/git/status_shortcuts.rb.legacy new file mode 100644 index 0000000..e121b8b --- /dev/null +++ b/lib/git/status_shortcuts.rb.legacy @@ -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 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("|") From f0ef9b06f63e274e6727ef3f4f57cf213eb4e353 Mon Sep 17 00:00:00 2001 From: Willa Drengwitz Date: Fri, 31 Aug 2018 13:51:34 -0400 Subject: [PATCH 4/6] Add back too status max files check --- lib/git/status_shortcuts.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/git/status_shortcuts.rb b/lib/git/status_shortcuts.rb index f76f1a5..4c6efe9 100755 --- a/lib/git/status_shortcuts.rb +++ b/lib/git/status_shortcuts.rb @@ -37,6 +37,7 @@ class GitStatus end def report + exit if all_changes.length > ENV["gs_max_changes"].to_i print_header print_groups puts filelist if @grouped_changes.any? From fc679a06ac4e1409e1d5853910acdc87970e969c Mon Sep 17 00:00:00 2001 From: Willa Drengwitz Date: Fri, 31 Aug 2018 13:53:51 -0400 Subject: [PATCH 5/6] Update strip color perl command --- test/support/test_helper.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/support/test_helper.sh b/test/support/test_helper.sh index ba9e472..a021b03 100644 --- a/test/support/test_helper.sh +++ b/test/support/test_helper.sh @@ -15,7 +15,8 @@ fi # Strip color codes from a string 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 From 750697e9257b732954907aafd843c2327f2b6d95 Mon Sep 17 00:00:00 2001 From: Willa Drengwitz Date: Fri, 31 Aug 2018 16:02:39 -0400 Subject: [PATCH 6/6] Add quotes around files with spaces --- lib/git/status_shortcuts.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/git/status_shortcuts.rb b/lib/git/status_shortcuts.rb index 4c6efe9..619d145 100755 --- a/lib/git/status_shortcuts.rb +++ b/lib/git/status_shortcuts.rb @@ -280,6 +280,7 @@ class GitChange < GitStatus 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