diff --git a/README.md b/README.md index 338cc22..60ed7e8 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,22 @@ as `gs` for the extended `git status`, and `ga` for the `git add` function. If you already have an alias like `alias gco="git checkout"`, you can now type `gco 1` to checkout the first file in the output of SCM Breeze's `git status`. +## Custom emojis for username and "staff" group + +The `ll` command adds numbered shortcuts to files, but another fun feature is replacing your +username and the "staff" group with custom emojis. You can set these in `~/.user_sym` and `~/.staff_sym`. + +Custom user and staff emojis + +Set your own emojis by running: + +```bash +echo 🍀 > ~/.user_sym +echo 🖥 > ~/.staff_sym +``` + +I also like using `~/.user_sym` [in my Bash prompt](https://github.com/ndbroadbent/dotfiles/blob/master/bashrc/prompt.sh#L71). + ## Notes about Tab Completion for Aliases diff --git a/docs/images/custom_user_and_staff_symbols.jpg b/docs/images/custom_user_and_staff_symbols.jpg new file mode 100644 index 0000000..b65e055 Binary files /dev/null and b/docs/images/custom_user_and_staff_symbols.jpg differ diff --git a/git.scmbrc.example b/git.scmbrc.example index a1ccca3..287bfaa 100644 --- a/git.scmbrc.example +++ b/git.scmbrc.example @@ -53,6 +53,7 @@ git_diff_file_alias="gdf" git_diff_word_alias="gdw" git_diff_cached_alias="gdc" git_difftool_alias="gdt" +git_mergetool_alias="gmt" # 3. Standard commands git_clone_alias="gcl" git_fetch_alias="gf" @@ -112,9 +113,10 @@ git_pull_request_alias="gpr" # ---------------------------------------------- # Keyboard shortcuts are on by default. Set this to 'false' to disable them. git_keyboard_shortcuts_enabled="true" -git_commit_all_keys="\C-x " # CTRL+x, SPACE -git_add_and_commit_keys="\C-xc" # CTRL+x, c -git_commit_all_with_ci_skip_keys="\C-xv" # CTRL+x, v (Appends [ci skip] to commit message) +git_commit_all_keys="\C-x " # CTRL+x, SPACE +git_add_and_commit_keys="\C-xc" # CTRL+x, c +git_commit_all_with_ci_skip_keys="\C-xv" # CTRL+x, v (Appends [ci skip] to message) +git_add_and_amend_commit_keys="\C-xz" # CTRL+x, z # Shell Command Wrapping diff --git a/lib/git/aliases.sh b/lib/git/aliases.sh index b7b3f84..b8184c3 100644 --- a/lib/git/aliases.sh +++ b/lib/git/aliases.sh @@ -113,6 +113,7 @@ if [ "$git_setup_aliases" = "yes" ]; then __git_alias "$git_add_patch_alias" 'git' 'add' '-p' __git_alias "$git_add_updated_alias" 'git' 'add' '-u' __git_alias "$git_difftool_alias" 'git' 'difftool' + __git_alias "$git_mergetool_alias" 'git' 'mergetool' # Custom default format for git log git_log_command="log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit" diff --git a/lib/git/keybindings.sh b/lib/git/keybindings.sh index b286075..4bbca42 100644 --- a/lib/git/keybindings.sh +++ b/lib/git/keybindings.sh @@ -35,11 +35,13 @@ if [[ "$git_keyboard_shortcuts_enabled" = "true" ]]; then if [[ $shell == "zsh" ]]; then _bind "$git_commit_all_keys" " git_commit_all""$RETURN_CHAR" _bind "$git_add_and_commit_keys" " \033[1~ git_add_and_commit ""$RETURN_CHAR" - _bind "$git_commit_all_with_ci_skip_keys" " \033[1~ APPEND='[ci skip]' git_commit_all ""$RETURN_CHAR" + _bind "$git_commit_all_with_ci_skip_keys" " \033[1~ GIT_COMMIT_MSG_SUFFIX='[ci skip]' git_commit_all ""$RETURN_CHAR" + _bind "$git_add_and_amend_commit_keys" " git add --all . && git commit --amend -C HEAD""$RETURN_CHAR" else _bind "$git_commit_all_keys" "\" git_commit_all$RETURN_CHAR\"" _bind "$git_add_and_commit_keys" "\"\C-A git_add_and_commit $RETURN_CHAR\"" - _bind "$git_commit_all_with_ci_skip_keys" "\"\C-A APPEND='[ci skip]' git_commit_all $RETURN_CHAR\"" + _bind "$git_commit_all_with_ci_skip_keys" "\"\C-A GIT_COMMIT_MSG_SUFFIX='[ci skip]' git_commit_all $RETURN_CHAR\"" + _bind "$git_add_and_amend_commit_keys" "\" git add --all . && git commit --amend -C HEAD$RETURN_CHAR\"" fi fi diff --git a/lib/git/shell_shortcuts.sh b/lib/git/shell_shortcuts.sh index 03348a1..49d9b81 100644 --- a/lib/git/shell_shortcuts.sh +++ b/lib/git/shell_shortcuts.sh @@ -156,13 +156,24 @@ if [ "$shell_ls_aliases_enabled" = "true" ] && builtin command -v ruby > /dev/nu # After : -rw-rw-r-- 1 𝐍 𝐍 1.1K Sep 19 21:39 scm_breeze.sh if [ -e "$HOME"/.user_sym ]; then # Little bit of ruby golf to rejustify the user/group/size columns after replacement + # TODO(ghthor): Convert this to a cat < parameter to just show one modification state # # groups => 1: staged, 2: unmerged, 3: unstaged, 4: untracked # -------------------------------------------------------------------- -# -require 'strscan' -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 +@project_root = File.exist?(".git") ? Dir.pwd : `\git rev-parse --show-toplevel 2> /dev/null`.strip - def report - exit if all_changes.length > ENV["gs_max_changes"].to_i - print_header - print_groups - puts filelist if @grouped_changes.any? - end +@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" +} - ######### Parsing methods ######### +# Following colors must be prepended with modifiers e.g. '\033[1;', '\033[0;' +@group_c = { + :staged => "33m", + :unmerged => "31m", + :unstaged => "32m", + :untracked => "36m" +} - def get_status - `git status 2>/dev/null` - end +@stat_hash = { + :staged => [], + :unmerged => [], + :unstaged => [], + :untracked => [] +} - # 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/) - remote_line.match(/.*(\d*).*(\d*)/).captures - else - [remote_line[/is ahead of.*by (\d*).*/, 1], remote_line[/is behind.*by (\d*).*/, 1]] - end - end +@output_files = [] - # 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| - 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 +# Counter for env variables +@e = 0 - def scan_until_next_empty_line(scanner) - scanner.scan_until(/\n\n/) - end +# 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]}" : "" - # 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 +# 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 - 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 +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 select_groups - req = parse_request - GROUPS.select { |k, _| k == req } - end +def has_modules? + @has_modules ||= File.exists?(File.join(@project_root, '.gitmodules')) +end - def parse_request - if @request.match(/\d/) - GROUPS.keys[@request.to_i - 1] - else - @request.to_sym +# 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 - ######### Outputting methods ######### - - def print_header - puts delimiter(:header) + header - puts delimiter(:header) if anything_changed? + 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 - 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 + # Store data + @stat_hash[group] << {:msg => msg, :col => col, :file => file} if msg - 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}" + # 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 -class GitChange < GitStatus - attr_reader :status +def relative_path(base, target) + back = "" + while target.sub(base,'') == target + base = base.sub(/\/[^\/]*$/, '') + back = "../#{back}" + end + "#{back}#{target.sub("#{base}/",'')}" +end - # Restructively singles out the submodules message and - # strips the remaining string to get rid of padding - def initialize(file_and_message, status) - @message = file_and_message.slice!(/\(.*\)/) - @file = file_and_message.strip - @file = (@file.include? " ") ? "\"#{@file}\"" : @file - @status = status.strip + +# 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 - def absolute_path - File.expand_path(@file, Dir.pwd) - end + puts "#{c_group}##{@c[:rst]}" # Extra '#' +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 +[[: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 - # 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) + # 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 -GitStatus.new(ARGV.first).report +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("|") diff --git a/lib/git/status_shortcuts.rb.legacy b/lib/git/status_shortcuts.rb.legacy deleted file mode 100644 index e121b8b..0000000 --- a/lib/git/status_shortcuts.rb.legacy +++ /dev/null @@ -1,218 +0,0 @@ -#!/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("|") diff --git a/lib/git/status_shortcuts.sh b/lib/git/status_shortcuts.sh index 04f0c96..54b3191 100644 --- a/lib/git/status_shortcuts.sh +++ b/lib/git/status_shortcuts.sh @@ -211,29 +211,62 @@ theirs(){ _git_resolve_merge_conflict "their" "$@"; } # * Add escaped commit command and unescaped message to bash history. git_commit_prompt() { local commit_msg + local saved_commit_msg + if [ -f "/tmp/.git_commit_message~" ]; then + saved_commit_msg="$(cat /tmp/.git_commit_message~)" + echo -e "\033[0;36mLeave blank to use saved commit message: \033[0m$saved_commit_msg" + fi if [[ $shell == "zsh" ]]; then vared -h -p "Commit Message: " commit_msg else read -r -e -p "Commit Message: " commit_msg fi - if [ -n "$commit_msg" ]; then - eval "$@" # run any prequisite commands - # Add $APPEND to commit message, if given. (Used to append things like [ci skip] for Travis CI) - if [ -n "$APPEND" ]; then commit_msg="$commit_msg $APPEND"; fi - echo $commit_msg | git commit -F - | tail -n +2 - else - echo -e "\033[0;31mAborting commit due to empty commit message.\033[0m" + if [ -z "$commit_msg" ]; then + if [ -n "$saved_commit_msg" ]; then + commit_msg="$saved_commit_msg" + else + echo -e "\033[0;31mAborting commit due to empty commit message.\033[0m" + return + fi fi - escaped=$(echo "$commit_msg" | sed -e 's/"/\\"/g' -e 's/!/"'"'"'!'"'"'"/g') + # Add $GIT_COMMIT_MSG_SUFFIX to commit message, if given. + # (Used to append things like [ci skip] for Travis CI) + if [ -n "$GIT_COMMIT_MSG_SUFFIX" ]; then + commit_msg="$commit_msg $GIT_COMMIT_MSG_SUFFIX" + fi + + # Exclamation marks are really difficult to escape properly in a bash prompt. + # They must always be enclosed with single quotes. + escaped_msg=$(echo "$commit_msg" | sed -e 's/"/\\"/g' -e "s/!/\"'!'\"/g") + # Add command to bash history, so that if a git pre-commit hook fails, + # you can just press "up" and "return" to retry the commit. if [[ $shell == "zsh" ]]; then - print -s "git commit -m \"${escaped//\\/\\\\}\"" # zsh's print needs double escaping - print -s "$commit_msg" + # zsh's print needs double escaping + print -s "git commit -m \"${escaped_msg//\\/\\\\}\"" else - echo "git commit -m \"$escaped\"" >> $HISTFILE - # Also add unescaped commit message, for git prompt - echo "$commit_msg" >> $HISTFILE + history -s "git commit -m \"$escaped_msg\"" + # Need to write history to a file for tests + if [ -n "$SHUNIT_VERSION" ]; then history -w $HISTFILE; fi + fi + + # Also save the commit message to a temp file in case git commit fails + echo "$commit_msg" > "/tmp/.git_commit_message~" + eval $@ # run any prequisite commands + + echo "$commit_msg" | git commit -F - | tail -n +2 + + # Fetch the pipe status (for both bash and zsh): + GIT_PIPE_STATUS=("${PIPESTATUS[@]}${pipestatus[@]}") + if [[ $shell == "zsh" ]]; then + git_exit_status="${GIT_PIPE_STATUS[2]}" # zsh array indexes start at 1 + else + git_exit_status="${GIT_PIPE_STATUS[1]}" + fi + if [[ "$git_exit_status" == 0 ]]; then + # Delete saved commit message if commit was successful + rm -f "/tmp/.git_commit_message~" fi } @@ -242,8 +275,8 @@ git_commit_all() { fail_if_not_git_repo || return 1 changes=$(git status --porcelain | wc -l | tr -d ' ') if [ "$changes" -gt 0 ]; then - if [ -n "$APPEND" ]; then - local appending=" | \033[0;36mappending '\033[1;36m$APPEND\033[0;36m' to commit message.\033[0m" + if [ -n "$GIT_COMMIT_MSG_SUFFIX" ]; then + local appending=" | \033[0;36mappending '\033[1;36m$GIT_COMMIT_MSG_SUFFIX\033[0;36m' to commit message.\033[0m" fi echo -e "\033[0;33mCommitting all files (\033[0;31m$changes\033[0;33m)\033[0m$appending" git_commit_prompt "git add --all ." diff --git a/lib/git/status_shortcuts_refactor.rb b/lib/git/status_shortcuts_refactor.rb new file mode 100644 index 0000000..619d145 --- /dev/null +++ b/lib/git/status_shortcuts_refactor.rb @@ -0,0 +1,328 @@ +#!/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 + 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 + + def report + exit if all_changes.length > ENV["gs_max_changes"].to_i + print_header + print_groups + puts filelist if @grouped_changes.any? + end + + + ######### 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/) + remote_line.match(/.*(\d*).*(\d*)/).captures + else + [remote_line[/is ahead of.*by (\d*).*/, 1], remote_line[/is behind.*by (\d*).*/, 1]] + 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| + 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 + + # 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 + +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) + @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 diff --git a/test/lib/git/status_shortcuts_test.sh b/test/lib/git/status_shortcuts_test.sh index d8b9e47..eb3acae 100755 --- a/test/lib/git/status_shortcuts_test.sh +++ b/test/lib/git/status_shortcuts_test.sh @@ -311,9 +311,10 @@ test_git_commit_prompt() { if [[ $shell == "zsh" ]]; then test_history="$(history)" else + # Need to load history from $HISTFILE + # (Couldn't get the 'history' builtin to work during tests.) test_history="$(cat $HISTFILE)" fi - assertIncludes "$test_history" "$commit_msg" assertIncludes "$test_history" "git commit -m \"$dbl_escaped_msg\"" } @@ -335,7 +336,7 @@ test_git_commit_prompt_with_append() { # Test the git commit prompt, by piping a commit message # instead of user input. - echo "$commit_msg" | APPEND="[ci skip]" git_commit_prompt > /dev/null + echo "$commit_msg" | GIT_COMMIT_MSG_SUFFIX="[ci skip]" git_commit_prompt > /dev/null git_show_output=$(git show --oneline --name-only) assertIncludes "$git_show_output" "$commit_msg \[ci skip\]"