From e634d0c4b5a71a874ad360f1ad19426bcd74d087 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Tue, 18 Oct 2011 00:18:17 +0800 Subject: [PATCH] Initial Commit - moved out of ubuntu_config. --- .gitignore | 1 + .travis.yml | 2 + Gemfile | 3 + Gemfile.lock | 10 + README.markdown | 28 + Rakefile | 15 + git.scmbrc.example | 72 ++ install.sh | 10 + lib/_shared.sh | 8 + lib/bzr/BUILDME | 0 lib/git/_shared.sh | 8 + lib/git/aliases_and_bindings.sh | 123 +++ lib/git/fallback/status_shortcuts_shell.sh | 154 +++ lib/git/repo_management.sh | 229 +++++ lib/git/status_shortcuts.rb | 160 +++ lib/git/status_shortcuts.sh | 188 ++++ lib/git/tools.sh | 31 + lib/hg/BUILDME | 0 lib/svn/BUILDME | 0 scm_breeze.sh | 24 + test/lib/git/repo_management_test.sh | 185 ++++ test/lib/git/status_shortcuts_test.sh | 255 +++++ test/support/shunit2 | 1048 ++++++++++++++++++++ test/support/test_helper | 38 + 24 files changed, 2592 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.markdown create mode 100644 Rakefile create mode 100644 git.scmbrc.example create mode 100755 install.sh create mode 100644 lib/_shared.sh create mode 100644 lib/bzr/BUILDME create mode 100644 lib/git/_shared.sh create mode 100644 lib/git/aliases_and_bindings.sh create mode 100644 lib/git/fallback/status_shortcuts_shell.sh create mode 100644 lib/git/repo_management.sh create mode 100644 lib/git/status_shortcuts.rb create mode 100644 lib/git/status_shortcuts.sh create mode 100644 lib/git/tools.sh create mode 100644 lib/hg/BUILDME create mode 100644 lib/svn/BUILDME create mode 100644 scm_breeze.sh create mode 100755 test/lib/git/repo_management_test.sh create mode 100755 test/lib/git/status_shortcuts_test.sh create mode 100755 test/support/shunit2 create mode 100644 test/support/test_helper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe2da8a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +git.scmbrc diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..748adee --- /dev/null +++ b/.travis.yml @@ -0,0 +1,2 @@ +before_script: + - sudo apt-get install zsh diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2c8ae24 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'http://rubygems.org' + +gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..ef36ebe --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,10 @@ +GEM + remote: http://rubygems.org/ + specs: + rake (0.9.2) + +PLATFORMS + ruby + +DEPENDENCIES + rake diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..57330df --- /dev/null +++ b/README.markdown @@ -0,0 +1,28 @@ +# 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)** + +# ------------------------------------------------------- + +This is a collection of shell scripts (for `bash` and `zsh`) that enhance your interaction with git. + +* Numbered file shortcuts for git commands +* Repository management scripts for Git projects +* Symlink design assets + + +Pull requests always welcome! + + +### About + +These scripts have been refined and incubated in my bashrc for over a year, +and I decided that they deserved their own project. + +I've tried to make each section modular, so you are free to choose what parts you want to use. + +I also know that we grow attached to the aliases we use every day, so I won't force you to use mine. +I've given some example aliases at `aliases.example.sh`, but please customize them however you like. + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6fb0e8b --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +require 'rake' + +desc "Run shUnit2 tests" +task :test do + Dir.glob("test/**/*_test.sh").each do |test| + ["bash", "zsh"].each do |shell| + puts "== Running tests with [#{shell}]: #{test}" + @failed = !system("#{shell} #{test}") || @failed + end + end + exit @failed ? 1 : 0 +end + +task :default => ['test'] + diff --git a/git.scmbrc.example b/git.scmbrc.example new file mode 100644 index 0000000..61dbb12 --- /dev/null +++ b/git.scmbrc.example @@ -0,0 +1,72 @@ +# +# Git File Shortcuts Config +# ------------------------------------------------------------------------------ +# - Set your preferred prefix for env variable file shortcuts. +# (I chose 'e' because it is the easiest key to press after '$'.) +export git_env_char="e" +# - Max changes before reverting to 'git status'. git_status_shortcuts() may slow down for lots of changes. +export gs_max_changes="110" +# - Automatically use 'git rm' to remove deleted files when using the git_add_shorcuts() command? +export ga_auto_remove="yes" + + +# Git Repo Management Config +# -------------------------- +# Repos will be automatically added from this directory. +export GIT_REPO_DIR="$HOME/src" +# Add the full paths of any extra repos to GIT_REPOS, separated with ':' +# e.g. "/opt/rails/project:/opt/rails/another project:$HOME/other/repo" +export GIT_REPOS="" +export git_status_command="git_status_shortcuts" + + +# Alias configuration +# ------------------------------------------------------------------------------------ +git_alias="g" + +# 1. 'Git Breeze' functions +git_status_shortcuts_alias="gs" +git_add_shortcuts_alias="ga" +git_show_files_alias="gsf" +exec_git_expand_args_alias="ge" +# 2. Commands that handle paths (with shortcut args expanded) +git_checkout_alias="gco" +git_commit_alias="gc" +git_reset_alias="grs" +git_rm_alias="grm" +git_blame_alias="gbl" +git_diff_alias="gd" +git_diff_cached_alias="gdc" +# 3. Standard commands +git_clone_alias="gcl" +git_fetch_alias="gf" +git_fetch_and_rebase_alias="gfr" +git_pull_alias="gpl" +git_push_alias="gps" +git_status_original_alias="gst" +git_status_short_alias="gss" +git_add_all_alias="gaa" +git_commit_all_alias="gca" +git_commit_amend_alias="gcm" +git_commit_amend_no_msg_alias="gcmh" +git_remote_alias="gr" +git_branch_alias="gb" +git_branch_all_alias="gba" +git_rebase_alias="grb" +git_merge_alias="gm" +git_cherry_pick_alias="gcp" +git_log_alias="gl" +git_log_stat_alias="gls" +git_log_graph_alias="glg" +git_show_alias="gsh" + +# Git repo management +git_repo_alias="s" + + +# Keyboard shortcuts configuration +# --------------------------------------------- +git_status_shortcuts_keys="\C- " # CTRL+SPACE +git_commit_all_keys="\C-x " # CTRL+x SPACE +git_add_and_commit_keys="\C-xc" # CTRL+x c + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5244ec2 --- /dev/null +++ b/install.sh @@ -0,0 +1,10 @@ +# This loads Git Breeze into the shell session. +exec_string='[[ -s "$HOME/.scm_breeze/scm_breeze.sh" ]] && . "$HOME/.scm_breeze/scm_breeze.sh"' + +# Add line to bashrc and zshrc if not already present. +for rc in bashrc zshrc; do + if [[ -s "$HOME/.$rc" ]] && ! grep -q "$exec_string" "$HOME/.$rc"; then + echo -e "\n$exec_string" >> "$HOME/.$rc" + fi +done + diff --git a/lib/_shared.sh b/lib/_shared.sh new file mode 100644 index 0000000..3450d56 --- /dev/null +++ b/lib/_shared.sh @@ -0,0 +1,8 @@ +# Detect shell +if [ -n "${ZSH_VERSION:-}" ]; then shell="zsh"; else shell="bash"; fi +# Detect whether zsh 'shwordsplit' option is on by default. +if [[ $shell == "zsh" ]]; then zsh_shwordsplit=$((setopt | grep -q shwordsplit) && echo "true"); fi +# Switch on/off shwordsplit for functions that require it. +zsh_compat(){ if [[ $shell == "zsh" && -z $zsh_shwordsplit ]]; then setopt shwordsplit; fi; } +zsh_reset(){ if [[ $shell == "zsh" && -z $zsh_shwordsplit ]]; then unsetopt shwordsplit; fi; } + diff --git a/lib/bzr/BUILDME b/lib/bzr/BUILDME new file mode 100644 index 0000000..e69de29 diff --git a/lib/git/_shared.sh b/lib/git/_shared.sh new file mode 100644 index 0000000..3450d56 --- /dev/null +++ b/lib/git/_shared.sh @@ -0,0 +1,8 @@ +# Detect shell +if [ -n "${ZSH_VERSION:-}" ]; then shell="zsh"; else shell="bash"; fi +# Detect whether zsh 'shwordsplit' option is on by default. +if [[ $shell == "zsh" ]]; then zsh_shwordsplit=$((setopt | grep -q shwordsplit) && echo "true"); fi +# Switch on/off shwordsplit for functions that require it. +zsh_compat(){ if [[ $shell == "zsh" && -z $zsh_shwordsplit ]]; then setopt shwordsplit; fi; } +zsh_reset(){ if [[ $shell == "zsh" && -z $zsh_shwordsplit ]]; then unsetopt shwordsplit; fi; } + diff --git a/lib/git/aliases_and_bindings.sh b/lib/git/aliases_and_bindings.sh new file mode 100644 index 0000000..e53bdb2 --- /dev/null +++ b/lib/git/aliases_and_bindings.sh @@ -0,0 +1,123 @@ +# +# Set up configured aliases & keyboard shortcuts +# -------------------------------------------------------------------- + +# Git Breeze functions +alias $git_status_shortcuts_alias="git_status_shortcuts" +alias $git_add_shortcuts_alias="git_add_shorcuts" +alias $exec_git_expand_args_alias="exec_git_expand_args" +alias $git_show_files_alias="git_show_affected_files" +alias $git_commit_all_alias='git_commit_all' + +# Expand numbers and ranges for commands that deal with paths +_exp="exec_git_expand_args" +alias $git_checkout_alias="$_exp git checkout" +alias $git_commit_alias="$_exp git commit" +alias $git_reset_alias="$_exp git reset" +alias $git_rm_alias="$_exp git rm" +alias $git_blame_alias="$_exp git blame" +alias $git_diff_alias="$_exp git diff" +alias $git_diff_cached_alias="$_exp git diff --cached" + +# Standard commands +alias $git_clone_alias='git clone' +alias $git_fetch_alias='git fetch' +alias $git_fetch_and_rebase_alias='git fetch && git rebase' +alias $git_pull_alias='git pull' +alias $git_push_alias='git push' +alias $git_status_original_alias='git status' # (Standard git status) +alias $git_status_short_alias='git status -s' +alias $git_remote_alias='git remote -v' +alias $git_branch_alias='git branch' +alias $git_branch_all_alias='git branch -a' +alias $git_rebase_alias='git rebase' +alias $git_merge_alias='git merge' +alias $git_cherry_pick_alias='git cherry-pick' +alias $git_log_alias='git log' +alias $git_log_stat_alias='git log --stat --max-count=5' +alias $git_log_graph_alias='git log --graph --max-count=5' +alias $git_show_alias='git show' +alias $git_add_all_alias='git add -A' +alias $git_commit_amend_alias='git commit --amend' +# Add staged changes to latest commit without prompting for message +alias $git_commit_amend_no_msg_alias='git commit --amend -C HEAD' + +# Git repo management alias +alias $git_repo_alias="git_repo" # The 's' stands for 'switch' or 'sourcecode' + + +# Tab completion for aliases +if [[ $shell == "zsh" ]]; then + # Turn on support for bash completion + autoload bashcompinit + bashcompinit + + # -- zsh + compdef $git_alias=git + compdef _git $git_pull_alias=git-pull + compdef _git $git_push_alias=git-push + compdef _git $git_fetch_alias=git-fetch + compdef _git $git_fetch_and_rebase_alias=git-fetch + compdef _git $git_diff_alias=git-diff + compdef _git $git_commit_alias=git-commit + compdef _git $git_commit_all_alias=git-commit + compdef _git $git_checkout_alias=git-checkout + compdef _git $git_branch_alias=git-branch + compdef _git $git_branch_all_alias=git-branch + compdef _git $git_log_alias=git-log + compdef _git $git_log_stat_alias=git-log + compdef _git $git_log_graph_alias=git-log + compdef _git $git_add_shortcuts_alias=git-add + compdef _git $git_merge_alias=git-merge +else + # -- bash + complete -o default -o nospace -F _git $git_alias + complete -o default -o nospace -F _git_pull $git_pull_alias + complete -o default -o nospace -F _git_push $git_push_alias + complete -o default -o nospace -F _git_fetch $git_fetch_alias + complete -o default -o nospace -F _git_branch $git_branch_alias + complete -o default -o nospace -F _git_rebase $git_rebase_alias + complete -o default -o nospace -F _git_merge $git_merge_alias + complete -o default -o nospace -F _git_log $git_log_alias + complete -o default -o nospace -F _git_diff $git_diff_alias + complete -o default -o nospace -F _git_checkout $git_checkout_alias + complete -o default -o nospace -F _git_remote $git_remote_alias + complete -o default -o nospace -F _git_show $git_show_alias +fi + +# Git repo management & aliases. +# If you know how to rewrite _git_repo_tab_completion() for zsh, please send me a pull request! +complete -o nospace -o filenames -F _git_repo_tab_completion git_repo +complete -o nospace -o filenames -F _git_repo_tab_completion $git_repo_alias + + +# Keyboard Bindings +# ----------------------------------------------------------- +# 'git_commit_all' and 'git_add_and_commit' give commit message prompts. +# See [here](http://qntm.org/bash#sec1) for info about why I wanted a prompt. + +# Cross-shell key bindings +_bind(){ + if [[ $shell == "zsh" ]]; then + bindkey -s "$1" "$2" # zsh + else + bind "\"$1\": \"$2\"" # bash + fi +} + +case "$TERM" in +xterm*|rxvt*) + # CTRL-SPACE => $ git_status_shortcuts {ENTER} + _bind "$git_status_shortcuts_keys" " git_status_shortcuts\n" + # CTRL-x-SPACE => $ git_commit_all {ENTER} + _bind "$git_commit_all_keys" " git_commit_all\n" + # CTRL-x-c => $ git_add_and_commit {ENTER} + # 1 3 CTRL-x-c => $ git_add_and_commit 1 3 {ENTER} + _bind "$git_add_and_commit_keys" "\e[1~ git_add_and_commit \n" + + # Commands are prepended with a space so that they won't be added to history. + # Make sure this is turned on with: + # zsh: setopt histignorespace histignoredups + # bash: HISTCONTROL=ignorespace:ignoredups +esac + diff --git a/lib/git/fallback/status_shortcuts_shell.sh b/lib/git/fallback/status_shortcuts_shell.sh new file mode 100644 index 0000000..6e69415 --- /dev/null +++ b/lib/git/fallback/status_shortcuts_shell.sh @@ -0,0 +1,154 @@ +# ------------------------------------------------------------------------------ +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------------------------------ +# +# bash/zsh 'git_status_shortcuts' implementation, in case Ruby is not installed. +# Of course, I wrote this function first, and then rewrote it in Ruby. +# +# 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 +# -------------------------------------------------------------------- +git_status_shortcuts() { + zsh_compat # Ensure shwordsplit is on for zsh + local IFS=$'\n' + local git_status="$(git status --porcelain 2> /dev/null)" + + if [ -n "$git_status" ] && [[ $(echo "$git_status" | wc -l) -le $gs_max_changes ]]; then + unset stat_file; unset stat_col; unset stat_msg; unset stat_grp; unset stat_x; unset stat_y + # Clear numbered env variables. + for (( i=1; i<=$gs_max_changes; i++ )); do unset $git_env_char$i; done + + # Get branch + local branch=`git branch 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/\1/'` + # Get project root + if [ -d .git ]; then + local project_root="$PWD" + else + local project_root=$(git rev-parse --git-dir 2> /dev/null | sed "s%/\.git$%%g") + fi + + # Colors + local c_rst="\e[0m" + local c_branch="\e[1m" + local c_header="\e[0m" + local c_dark="\e[2;37m" + local c_del="\e[0;31m" + local c_mod="\e[0;32m" + local c_new="\e[0;33m" + local c_ren="\e[0;34m" + local c_cpy="\e[0;33m" + local c_ign="\e[0;36m" + # Following colors must be prepended with modifiers e.g. '\e[1;', '\e[0;' + local c_grp_1="33m"; local c_grp_2="31m"; local c_grp_3="32m"; local c_grp_4="36m" + + local f=1; local e=1 # Counters for number of files, and ENV variables + + echo -e "$c_dark#$c_rst On branch: $c_branch$branch$c_rst $c_dark| [$c_rst*$c_dark]$c_rst => \$$git_env_char*\n$c_dark#$c_rst" + + for line in $git_status; do + if [[ $shell == *bash ]]; then + x=${line:0:1}; y=${line:1:1}; file=${line:3} + else + x=$line[1]; y=$line[2]; file=$line[4,-1] + fi + + # Index modification states + msg="" + case "$x$y" in + "DD") msg=" both deleted"; col="$c_del"; grp="2";; + "AU") msg=" added by us"; col="$c_new"; grp="2";; + "UD") msg="deleted by them"; col="$c_del"; grp="2";; + "UA") msg=" added by them"; col="$c_new"; grp="2";; + "DU") msg=" deleted by us"; col="$c_del"; grp="2";; + "AA") msg=" both added"; col="$c_new"; grp="2";; + "UU") msg=" both modified"; col="$c_mod"; grp="2";; + "M"?) msg=" modified"; col="$c_mod"; grp="1";; + "A"?) msg=" new file"; col="$c_new"; grp="1";; + "D"?) msg=" deleted"; col="$c_del"; grp="1";; + "R"?) msg=" renamed"; col="$c_ren"; grp="1";; + "C"?) msg=" copied"; col="$c_cpy"; grp="1";; + "??") msg="untracked"; col="$c_ign"; grp="4";; + esac + if [ -n "$msg" ]; then + # Store data at array index and add to group + stat_file[$f]=$file; stat_msg[$f]=$msg; stat_col[$f]=$col + stat_grp[$grp]="${stat_grp[$grp]} $f" + let f++ + fi + + # Work tree modification states + msg="" + if [[ "$y" == "M" ]]; then msg=" modified"; col="$c_mod"; grp="3"; fi + # Don't show {Y} as deleted during a merge conflict. + if [[ "$y" == "D" && "$x" != "D" && "$x" != "U" ]]; then msg=" deleted"; col="$c_del"; grp="3"; fi + if [ -n "$msg" ]; then + stat_file[$f]=$file; stat_msg[$f]=$msg; stat_col[$f]=$col + stat_grp[$grp]="${stat_grp[$grp]} $f" + let f++ + fi + done + + IFS=" " + grp_num=1 + for heading in 'Changes to be committed' 'Unmerged paths' 'Changes not staged for commit' 'Untracked files'; do + # If no group specified as param, or specified group is current group + if [ -z "$1" ] || [[ "$1" == "$grp_num" ]]; then + local c_arrow="\e[1;$(eval echo \$c_grp_$grp_num)" + local c_hash="\e[0;$(eval echo \$c_grp_$grp_num)" + if [ -n "${stat_grp[$grp_num]}" ]; then + echo -e "$c_arrow➤$c_header $heading\n$c_hash#$c_rst" + _gs_output_file_group $grp_num + fi + fi + let grp_num++ + done + else + # This function will slow down if there are too many changed files, + # so just use plain 'git status' + git status + fi + zsh_reset # Reset zsh environment to default +} +# Template function for 'git_status_shortcuts'. +_gs_output_file_group() { + for i in ${stat_grp[$1]}; do + # Print colored hashes & files based on modification groups + local c_group="\e[0;$(eval echo -e \$c_grp_$1)" + + # Deduce relative path based on current working directory + if [ -z "$project_root" ]; then + relative="${stat_file[$i]}" + else + dest="$project_root/${stat_file[$i]}" + relative="$(_gs_relative_path "$PWD" "$dest" )" + fi + + if [[ $f -gt 10 && $e -lt 10 ]]; then local pad=" "; else local pad=""; fi # (padding) + echo -e "$c_hash#$c_rst ${stat_col[$i]}${stat_msg[$i]}:\ +$pad$c_dark [$c_rst$e$c_dark] $c_group$relative$c_rst" + # Export numbered variables in the order they are displayed. + # (Exports full path, but displays relative path) + export $git_env_char$e="$project_root/${stat_file[$i]}" + let e++ + done + echo -e "$c_hash#$c_rst" +} + +# Show relative path if current directory is not project root +_gs_relative_path(){ + # Credit to 'pini' for the following script. + # (http://stackoverflow.com/questions/2564634/bash-convert-absolute-path-into-relative-path-given-a-current-directory) + target=$2; common_part=$1; back="" + while [[ "${target#$common_part}" == "${target}" ]]; do + common_part="${common_part%/*}" + back="../${back}" + done + echo "${back}${target#$common_part/}" +} + diff --git a/lib/git/repo_management.sh b/lib/git/repo_management.sh new file mode 100644 index 0000000..b01b59a --- /dev/null +++ b/lib/git/repo_management.sh @@ -0,0 +1,229 @@ +# ------------------------------------------------------- +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------- + +# ------------------------------------------------------- +# Repository management scripts for Git projects +# ------------------------------------------------------- + + +# * The `git_repo` function makes it easy to list & switch between +# git projects in $GIT_REPO_DIR (default = ~/src) +# +# * Change directory to any of your git repos or submodules, with recursive tab completion. +# +# * A repository index will be created at $GIT_REPO_DIR/.git_index +# (Scanning for git projects and submodules can take a few seconds.) +# +# * Cache can be rebuilt by running: +# $ git_repo --rebuild-index +# ('--' commands have tab completion too.) +# +# * Ignores projects within an 'archive' folder. +# +# * Allows you to run batch commands across all your repositories: +# +# - Update every repo from their remote: 'git_repo --update-all' +# - Produce a count of repos for each host: 'git_repo --count-by-host' +# - Run a custom command for each repo: 'git_repo --batch-cmd ' +# +# Examples: +# +# $ git_repo --list +# # => Lists all git projects +# +# $ git_repo ub[TAB] +# # => Provides tab completion for all project folders that begin with 'ub' +# +# $ git_repo ubuntu_config +# # => Changes directory to ubuntu_config, and auto-updates code from git remote. +# +# $ git_repo buntu_conf +# # => Same result as `git_repo ubuntu_config` +# +# $ git_repo +# # => cd $GIT_REPO_DIR + + +function git_repo() { + local IFS=$'\n' + if [ -z "$1" ]; then + # Just change to $GIT_REPO_DIR if no params given. + cd $GIT_REPO_DIR + else + if [ "$1" = "--rebuild-index" ]; then + _rebuild_git_repo_index + elif [ "$1" = "--update-all" ]; then + _git_repo_git_update_all + elif [ "$1" = "--batch-cmd" ]; then + _git_repo_git_batch_cmd "${@:2:$(($#-1))}" # Pass all args except $1 + elif [ "$1" = "--list" ] || [ "$1" = "-l" ]; then + echo -e "$_bld_col$(_git_repo_count)$_txt_col Git repositories in $_bld_col$GIT_REPO_DIR$_txt_col:\n" + for repo in $(_git_repo_dirs_without_home); do + echo $(basename $repo) : $repo + done | sort | column -t -s ':' + elif [ "$1" = "--count-by-host" ]; then + echo -e "=== Producing a report of the number of repos per host...\n" + _git_repo_batch_cmd git remote -v | grep "origin.*(fetch)" | + sed -e "s/origin\s*//" -e "s/(fetch)//" | + sed -e "s/\(\([^/]*\/\/\)\?\([^@]*@\)\?\([^:/]*\)\).*/\1/" | + sort | uniq -c + echo + else + _check_git_repo_index + # Figure out which directory we need to change to. + local project=$(echo $1 | cut -d "/" -f1) + # Find base path of project + local base_path="$(grep "/$project$" "$GIT_REPO_DIR/.git_index")" + if [ -n "$base_path" ]; then + sub_path=$(echo $1 | sed "s:^$project::") + # Append subdirectories to base path + base_path="$base_path$sub_path" + fi + # Try partial matches + # - string at beginning of project + if [ -z "$base_path" ]; then base_path=$(_git_repo_dirs_without_home | grep -m1 "/$project"); fi + # - string anywhere in project + if [ -z "$base_path" ]; then base_path=$(_git_repo_dirs_without_home | grep -m1 "$project"); fi + # -------------------- + # Go to our base path + if [ -n "$base_path" ]; then + unset IFS + eval cd "$base_path" # eval turns ~ into $HOME + # Run git callback (either update or show changes), if we are in the root directory + if [ -z "${sub_path%/}" ]; then _git_repo_pull_or_status; fi + else + echo -e "$_wrn_col'$1' did not match any git repos in $GIT_REPO_DIR$_txt_col" + fi + fi + fi +} + +_git_repo_dirs_without_home() { + sed -e "s/--.*//" -e "s%$HOME%~%" $GIT_REPO_DIR/.git_index +} + +# Recursively searches for git repos in $GIT_REPO_DIR +function _find_git_repos() { + # Find all unarchived projects + local IFS=$'\n' + for repo in $(find "$GIT_REPO_DIR" -maxdepth 4 -name ".git" -type d \! -wholename '*/archive/*'); do + echo ${repo%/.git} # Return project folder, with trailing ':' + _find_git_submodules $repo # Detect any submodules + done +} + +# List all submodules for a git repo, if any. +function _find_git_submodules() { + if [ -e "$1/../.gitmodules" ]; then + grep "\[submodule" "$1/../.gitmodules" | sed "s%\[submodule \"%${1%/.git}/%g" | sed "s/\"]//g" + fi +} + + +# Rebuilds index of git repos in $GIT_REPO_DIR. +function _rebuild_git_repo_index() { + if [ "$1" != "--silent" ]; then echo -e "== Scanning $GIT_REPO_DIR for git repos & submodules..."; fi + # Get repos from src dir and custom dirs, then sort by basename + local IFS=$'\n' + for repo in $(echo -e "$(_find_git_repos)\n$(echo $GIT_REPOS | sed "s/:/\n/g")"); do + echo $(basename $repo | sed "s/ /_/g") $repo + done | sort | cut -d " " -f2- > "$GIT_REPO_DIR/.git_index" + + if [ "$1" != "--silent" ]; then + echo -e "===== Cached $_bld_col$(_git_repo_count)$_txt_col repos in $GIT_REPO_DIR/.git_index" + fi +} + +# Build index if empty +function _check_git_repo_index() { + if [ ! -f "$GIT_REPO_DIR/.git_index" ]; then + _rebuild_git_repo_index --silent + fi +} + +# Produces a count of repos in the tab completion index (excluding commands) +function _git_repo_count() { + echo $(sed -e "s/--.*//" "$GIT_REPO_DIR/.git_index" | grep . | wc -l) +} + +# If the working directory is clean, update the git repository. Otherwise, show changes. +function _git_repo_pull_or_status() { + if ! [ `git status --porcelain | wc -l` -eq 0 ]; then + # Fall back to 'git status' if git status alias isn't configured + if type $git_status_command 2>&1 | grep -qv "not found"; then + eval $git_status_command + else + git status + fi + else + # Check that a local 'origin' remote exists. + if (git remote -v | grep -q origin); then + branch=`parse_git_branch` + # Only update the git repo if it hasn't been touched for at least 6 hours. + if $(find ".git" -maxdepth 0 -type d -mmin +360 | grep -q "\.git"); then + # If we aren't on any branch, checkout master. + if [ "$branch" = "(no branch)" ]; then + echo -e "=== Checking out$_git_col master$_txt_col branch." + git checkout master + branch="master" + fi + echo -e "=== Updating '$branch' branch in $_bld_col$base_path$_txt_col from$_git_col origin$_txt_col... (Press Ctrl+C to cancel)" + # Pull the latest code from the server + git pull origin $branch + fi + fi + fi +} + +# Updates all git repositories with clean working directories. +function _git_repo_update_all() { + echo -e "== Updating code in $_bld_col$(_git_repo_count)$_txt_col repos...\n" + for base_path in $(sed -e "s/--.*//" "$GIT_REPO_DIR/.git_index" | grep . | sort); do + echo -e "===== Updating code in \e[1;32m$base_path\e[0m...\n" + cd "$base_path" + _git_repo_pull_or_status + done +} + +# Runs a command for all git repos +function _git_repo_batch_cmd() { + if [ -n "$1" ]; then + echo -e "== Running command for $_bld_col$(_git_repo_count)$_txt_col repos...\n" + for base_path in $(sed -e "s/--.*//" "$GIT_REPO_DIR/.git_index" | grep . | sort); do + cd "$base_path" + $@ + done + else + echo "Please give a command to run for all repos. (It may be useful to write your command as a function or script.)" + fi +} + + +# Bash tab completion function for git_repo() +function _git_repo_tab_completion() { + _check_git_repo_index + local curw + local IFS=$'\n' + COMPREPLY=() + curw=${COMP_WORDS[COMP_CWORD]} + + # If the first part of $curw matches a high-level directory, + # then match on sub-directories for that project + local project=$(echo "$curw" | cut -d "/" -f1) + local base_path=$(grep "/$project$" "$GIT_REPO_DIR/.git_index" | sed 's/ /\\ /g') + + # If matching path was found and curr string contains a /, then complete project sub-directories + if [[ -n "$base_path" && $curw == */* ]]; then + local search_path=$(echo "$curw" | sed "s:^${project/\\/\\\\\\}::") + COMPREPLY=($(compgen -d "$base_path$search_path" | grep -v "/.git" | sed -e "s:$base_path:$project:" -e "s:$:/:" )) + # Else, tab complete all the entries in .git_index, plus '--' commands + else + local commands="--list\n--rebuild-index\n--update-all\n--batch-cmd\n--count-by-host" + COMPREPLY=($(compgen -W '$(sed -e "s:.*/::" -e "s:$:/:" "$GIT_REPO_DIR/.git_index" | sort)$(echo -e "\n"$commands)' -- $curw)) + fi + return 0 +} + diff --git a/lib/git/status_shortcuts.rb b/lib/git/status_shortcuts.rb new file mode 100644 index 0000000..e6a461f --- /dev/null +++ b/lib/git/status_shortcuts.rb @@ -0,0 +1,160 @@ +#!/usr/bin/env ruby +# encoding: UTF-8 +# ------------------------------------------------------------------------------ +# Git Breeze - Streamline your git 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 --git-dir 2> /dev/null`.sub(/\/\.git$/, '').strip + +@git_status = `git status --porcelain 2> /dev/null` +# Exit if no changes +exit if @git_status == "" +git_branch = `git branch -v 2> /dev/null` +@branch = git_branch[/^\* ([^ ]*)/, 1] +@ahead = git_branch[/^\* [^ ]* *[^ ]* *\[ahead ?(\d+)\]/, 1] + +@changes = @git_status.split("\n") +# Exit if too many changes +exit if @changes.size > ENV["gs_max_changes"].to_i + +# Colors +@c = { + :rst => "\e[0m", + :del => "\e[0;31m", + :mod => "\e[0;32m", + :new => "\e[0;33m", + :ren => "\e[0;34m", + :cpy => "\e[0;33m", + :unt => "\e[0;36m", + :dark => "\e[2;37m", + :branch => "\e[1m", + :header => "\e[0m" +} + + +# Following colors must be prepended with modifiers e.g. '\e[1;', '\e[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 + +# Heading +ahead = @ahead ? " #{@c[:dark]}| #{@c[:new]}+#{@ahead}#{@c[:rst]}" : "" +puts "%s#%s On branch: %s#{@branch}#{ahead} %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] +] + + +# Index modification states +@changes.each do |change| + x, y, file = change[0, 1], change[1, 1], change[3..-1] + + 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 "??"; ["untracked", :unt, :untracked] + 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} + 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 = "\e[0;#{@group_c[group]}" + + @stat_hash[group].each do |h| + @e += 1 + padding = (@e < 10 && @changes.size >= 10) ? " " : "" + + rel_file = relative_path(Dir.pwd, File.join(@project_root, h[:file])) + + puts "#{c_group}##{@c[:rst]} #{@c[h[:col]]}#{h[:msg]}:\ +#{padding}#{@c[:dark]} [#{@c[:rst]}#{@e}#{@c[:dark]}] #{c_group}#{rel_file}#{@c[:rst]}" + # Save the ordered list of output files + @output_files << h[:file] + 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="\e[1;#{@group_c[group]}" + c_hash="\e[0;#{@group_c[group]}" + puts "#{c_arrow}➤#{@c[:header]} #{heading}\n#{c_hash}##{@c[:rst]}" + output_file_group(group) + end + end +end + +puts @output_files.map{|f| File.join(@project_root, f) }.join("|") + diff --git a/lib/git/status_shortcuts.sh b/lib/git/status_shortcuts.sh new file mode 100644 index 0000000..bb04c5e --- /dev/null +++ b/lib/git/status_shortcuts.sh @@ -0,0 +1,188 @@ +# ------------------------------------------------------------------------------ +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------------------------------ + +# ------------------------------------------------------------------------------ +# Numbered file shortcuts for git commands +# ------------------------------------------------------------------------------ + + +# 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 filter by modification state: +# 1 || staged, 2 || unmerged, 3 || unstaged, 4 || untracked +# -------------------------------------------------------------------- +git_status_shortcuts() { + # Run ruby script, store output + cmd_output=$(/usr/bin/env ruby "$gitbreezeDir/lib/git/status_shortcuts.rb" $@) + if [[ -z "$cmd_output" ]]; then + # Just show regular git status if ruby script returns nothing. + git status; return 1 + fi + git_clear_vars + # Fetch list of files from last line of script output + files="$(echo "$cmd_output" | tail -n 1)" + # Export numbered env variables for each file + local IFS="|" + e=1; for file in $files; do export $git_env_char$e="$file"; let e++; done + + # Print status + echo "$cmd_output" | head -n -1 +} + + + +# 'git add' & 'git rm' wrapper +# This shortcut means 'stage the change to the file' +# i.e. It will add new and changed files, and remove deleted files. +# Should be used in conjunction with the git_status_shortcuts() function for 'git status'. +# - 'auto git rm' behaviour can be turned off +# ------------------------------------------------------------------------------- +git_add_shorcuts() { + if [ -z "$1" ]; then + echo "Usage: ga => git add " + echo " ga 1 => git add \$e1" + echo " ga 2..4 => git add \$e2 \$e3 \$e4" + echo " ga 2 5..7 => git add \$e2 \$e5 \$e6 \$e7" + if [[ $ga_auto_remove == "yes" ]]; then + echo -e "\nNote: Deleted files will also be staged using this shortcut." + echo " To turn off this behaviour, change the 'auto_remove' option." + fi + else + git_silent_add_shorcuts "$@" + # Makes sense to run 'git status' after this command. + git_status_shortcuts + fi +} +# Does nothing if no args are given. +git_silent_add_shorcuts() { + if [ -n "$1" ]; then + # Expand args and process resulting set of files. + for file in $(git_expand_args "$@"); do + # Use 'git rm' if file doesn't exist and 'ga_auto_remove' is enabled. + if [[ $ga_auto_remove == "yes" ]] && ! [ -e $file ]; then + echo -n "# " + git rm $file + else + git add $file + echo -e "# add '$file'" + fi + done + echo "#" + fi +} + + +# Prints a list of all files affected by a given SHA1, +# and exports numbered environment variables for each file. +git_show_affected_files(){ + f=0 # File count + # Show colored revision and commit message + echo -n "# "; script -q -c "git show --oneline --name-only $@" /dev/null | sed "s/\r//g" | head -n 1; echo "# " + for file in $(git show --pretty="format:" --name-only $@ | grep -v '^$'); do + let f++ + export $git_env_char$f=$file # Export numbered variable. + echo -e "# \e[2;37m[\e[0m$f\e[2;37m]\e[0m $file" + done; echo "# " +} + + +# Allows expansion of numbered shortcuts, ranges of shortcuts, or standard paths. +# Numbered shortcut variables are produced by various commands, such as: +# * git_status_shortcuts() - git status implementation +# * git_show_affected_files() - shows files affected by a given SHA1, etc. +git_expand_args() { + files="" + for arg in "$@"; do + if [[ "$arg" =~ ^[0-9]+$ ]] ; then # Substitute $e{*} variables for any integers + files="$files $(eval echo \$$git_env_char$arg)" + elif [[ $arg =~ ^[0-9]+\.\.[0-9]+$ ]]; then # Expand ranges into $e{*} variables + for i in $(seq $(echo $arg | tr ".." " ")); do + files="$files $(eval echo \$$git_env_char$i)" + done + else # Otherwise, treat $arg as a normal string. + files="$files $arg" + fi + done + echo $files +} +# Execute a command with expanded args, e.g. Delete files 6 to 12: $ ge rm 6..12 +exec_git_expand_args() { $(git_expand_args "$@"); } + +# Clear numbered env variables +git_clear_vars() { + for (( i=1; i<=$gs_max_changes; i++ )); do + # Stop clearing after first empty var + if [[ -z "$(eval echo "\$$git_env_char$i")" ]]; then break; fi + unset $git_env_char$i + done +} + + +# Shortcuts for resolving merge conflicts. +ours(){ local files=$(git_expand_args "$@"); git checkout --ours $files; git add $files; } +theirs(){ local files=$(git_expand_args "$@"); git checkout --theirs $files; git add $files; } + + +# Git commit prompts +# ------------------------------------------------------------------------------ + +# * Prompt for commit message +# * Execute prerequisite commands if message given, abort if not +# * Pipe commit message to 'git commit' +# * Add escaped commit command and unescaped message to bash history. +git_commit_prompt() { + local commit_msg + if [[ $shell == "zsh" ]]; then + # zsh 'read' is weak. If you know how to make this better, please send a pull request. + # (Bash 'read' supports prompt, arrow keys, home/end, up through bash history, etc.) + echo -n "Commit Message: "; read commit_msg + else + read -r -e -p "Commit Message: " commit_msg + fi + + if [ -n "$commit_msg" ]; then + eval $@ # run any prequisite commands + echo $commit_msg | git commit -F - | tail -n +2 + else + echo -e "\e[0;31mAborting commit due to empty commit message.\e[0m" + fi + escaped=$(echo "$commit_msg" | sed -e 's/"/\\"/g' -e 's/!/"'"'"'!'"'"'"/g') + + if [[ $shell == "zsh" ]]; then + print -s "git commit -m \"${escaped//\\/\\\\}\"" # zsh's print needs double escaping + print -s "$commit_msg" + else + echo "git commit -m \"$escaped\"" >> $HISTFILE + # Also add unescaped commit message, for git prompt + echo "$commit_msg" >> $HISTFILE + fi +} + +# Prompt for commit message, then commit all modified and untracked files. +git_commit_all() { + changes=$(git status --porcelain | wc -l) + if [ "$changes" -gt 0 ]; then + echo -e "\e[0;33mCommitting all files (\e[0;31m$changes\e[0;33m)\e[0m" + git_commit_prompt "git add -A" + else + echo "# No changed files to commit." + fi +} + +# Add paths or expanded args if any given, then commit all staged changes. +git_add_and_commit() { + git_silent_add_shorcuts "$@" + changes=$(git diff --cached --numstat | wc -l) + if [ "$changes" -gt 0 ]; then + git_status_shortcuts 1 # only show staged changes + git_commit_prompt + else + echo "# No staged changes to commit." + fi +} + diff --git a/lib/git/tools.sh b/lib/git/tools.sh new file mode 100644 index 0000000..0be534b --- /dev/null +++ b/lib/git/tools.sh @@ -0,0 +1,31 @@ +# ------------------------------------------------------- +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------- + +# ----------------------------------------------------------------- +# Misc Git Tools +# - Please feel free to add your own git scripts, and send me a pull request +# at https://github.com/ndbroadbent/scm_breeze +# ----------------------------------------------------------------- + + +# Remove files/folders from git history +# ------------------------------------------------------------------- +# To use it, cd to your repository's root and then run the function +# with a list of paths you want to delete. e.g. git_remove_history path1 path2 +# Original Author: David Underhill +git_remove_history() { + # Make sure we're at the root of a git repo + if [ ! -d .git ]; then + echo "Error: must run this script from the root of a git repository" + return + fi + # Remove all paths passed as arguments from the history of the repo + files=$@ + git filter-branch --index-filter "git rm -rf --cached --ignore-unmatch $files" HEAD + # Remove the temporary history git-filter-branch otherwise leaves behind for a long time + rm -rf .git/refs/original/ && git reflog expire --all && git gc --aggressive --prune +} + diff --git a/lib/hg/BUILDME b/lib/hg/BUILDME new file mode 100644 index 0000000..e69de29 diff --git a/lib/svn/BUILDME b/lib/svn/BUILDME new file mode 100644 index 0000000..e69de29 diff --git a/scm_breeze.sh b/scm_breeze.sh new file mode 100644 index 0000000..b8cedc6 --- /dev/null +++ b/scm_breeze.sh @@ -0,0 +1,24 @@ +# +# Get directory of this file (for bash and zsh). +# git_breeze.sh must not be run directly. +# It must be sourced, e.g "source ~/.git_breeze/git_breeze.sh" +# ------------------------------------------------------------ + +export gitbreezeDir="$(dirname ${BASH_SOURCE:-$0})" + +# Load config +. "$HOME/.git.scmbrc" + +. "$gitbreezeDir/lib/_shared.sh" +. "$gitbreezeDir/lib/git/aliases_and_bindings.sh" +. "$gitbreezeDir/lib/git/status_shortcuts.sh" +. "$gitbreezeDir/lib/git/repo_management.sh" +. "$gitbreezeDir/lib/git/tools.sh" + + +if ! type ruby > /dev/null 2>&1; then + # If Ruby is not installed, fall back to the + # slower bash/zsh implementation of 'git_status_shortcuts' + . "$gitbreezeDir/lib/git/fallback/status_shortcuts_shell.sh" +fi + diff --git a/test/lib/git/repo_management_test.sh b/test/lib/git/repo_management_test.sh new file mode 100755 index 0000000..9df3340 --- /dev/null +++ b/test/lib/git/repo_management_test.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# ------------------------------------------------------------------------------ +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------------------------------ +# +# Unit tests for git shell scripts + +thisDir="$( cd -P "$( dirname "$0" )" && pwd )" + +# Zsh compatibility +if [ -n "${ZSH_VERSION:-}" ]; then shell="zsh"; SHUNIT_PARENT=$0; setopt shwordsplit; fi + +# Load test helpers +. "$thisDir/../../support/test_helper" + +# Load functions to test +. "$thisDir/../../../lib/_shared.sh" +. "$thisDir/../../../lib/git/repo_management.sh" + + +# Setup and tear down +#----------------------------------------------------------------------------- +oneTimeSetUp() { + GIT_REPO_DIR=$(mktemp -d) + GIT_REPOS="/tmp/test_repo_1:/tmp/test_repo_11" + git_status_command="git status" + + git_index_file="$GIT_REPO_DIR/.git_index" + + silentGitCommands + + cd $GIT_REPO_DIR + # Setup test repos in temp repo dir + for repo in github bitbucket source_forge; do + mkdir $repo; cd $repo; git init; cd - > /dev/null + done + + # Add some nested dirs for testing resursive tab completion + mkdir -p github/videos/octocat/live_action + # Add hidden dir to test that '.git' is filtered, but other hidden dirs are available. + mkdir -p github/.im_hidden + + # Setup a test repo with some submodules + # (just a dummy '.gitmodules' file and some nested .git directories) + mkdir submodules_everywhere + cd submodules_everywhere + git init + cat > .gitmodules < /dev/null + done + + # Setup some custom repos outside the main repo dir + local IFS=":" + for dir in $GIT_REPOS; do + mkdir -p $dir; cd $dir; git init; + done + unset IFS + + verboseGitCommands + + cd "$orig_cwd" +} + +oneTimeTearDown() { + rm -rf "${GIT_REPO_DIR}" + local IFS=":" + for dir in $GIT_REPOS; do rm -rf $dir; done +} + +ensureIndex() { + _check_git_repo_index +} + +index_no_newlines() { + cat $git_index_file | tr "\\n" " " +} + + +#----------------------------------------------------------------------------- +# Unit tests +#----------------------------------------------------------------------------- + +test_repo_index_command() { + git_repo --rebuild-index > /dev/null + + # Test that all repos are detected, and sorted alphabetically + assertIncludes "$(index_no_newlines)" "bitbucket.*\ +blue_submodule.*\ +github.*\ +green_submodule.*\ +red_submodule.*\ +source_forge.*\ +submodules_everywhere.*\ +test_repo_11.*\ +test_repo_1" + +} + +test_check_git_repo_index() { + ensureIndex + echo "should not be regenerated" >> $git_index_file + _check_git_repo_index + # Test that index is not rebuilt unless empty + assertIncludes "$(index_no_newlines)" "should not be regenerated" + rm $git_index_file + # Test the index is rebuilt + _check_git_repo_index + assertTrue "[ -f $git_index_file ]" +} + +test_git_repo_count() { + assertEquals "9" "$(_git_repo_count)" +} + +test_repo_list() { + ensureIndex + list=$(git_repo --list) + assertIncludes "$list" "bitbucket" || return + assertIncludes "$list" "blue_submodule" || return + assertIncludes "$list" "test_repo_11" +} + +# Test matching rules for changing directory +test_git_repo_changing_directory() { + ensureIndex + git_repo "github"; assertEquals "$GIT_REPO_DIR/github" "$PWD" + git_repo "github/"; assertEquals "$GIT_REPO_DIR/github" "$PWD" + git_repo "bucket"; assertEquals "$GIT_REPO_DIR/bitbucket" "$PWD" + git_repo "green_sub"; assertEquals "$GIT_REPO_DIR/submodules_everywhere/very/nested/directory/green_submodule" "$PWD" + git_repo "_submod"; assertEquals "$GIT_REPO_DIR/submodules_everywhere/very/nested/directory/blue_submodule" "$PWD" + git_repo "test_repo_1"; assertEquals "/tmp/test_repo_1" "$PWD" + git_repo "test_repo_11"; assertEquals "/tmp/test_repo_11" "$PWD" + git_repo "test_repo_"; assertEquals "/tmp/test_repo_11" "$PWD" + git_repo "github/videos/octocat/live_action"; assertEquals "$GIT_REPO_DIR/github/videos/octocat/live_action" "$PWD" +} + +test_git_repo_tab_completion() { + # Only run tab completion test for bash + if [[ "$0" == *bash ]]; then + ensureIndex + COMP_CWORD=0 + + # Test that '--' commands have tab completion + COMP_WORDS="--" + _git_repo_tab_completion + assertEquals "Incorrect number of tab-completed '--' commands" "5" "$(tab_completions | wc -w)" + + COMP_WORDS="gith" + _git_repo_tab_completion + assertIncludes "$(tab_completions)" "github/" + + # Test completion for project sub-directories when project ends with '/' + COMP_WORDS="github/" + _git_repo_tab_completion + assertIncludes "$(tab_completions)" "github/videos/" + # Check that '.git/' is filtered from completion, but other hidden dirs are available + assertNotIncludes "$(tab_completions)" "github/.git/" + assertIncludes "$(tab_completions)" "github/.im_hidden/" + + COMP_WORDS="github/videos/" + _git_repo_tab_completion + assertIncludes "$(tab_completions)" "github/videos/octocat/" + + + # Test that completion checks for other matching projects even if one matches perfectly + COMP_WORDS="test_repo_1" + _git_repo_tab_completion + assertIncludes "$(tab_completions)" "test_repo_1/ test_repo_11/" + fi +} + + +# load and run shUnit2 +# Call this function to run tests +. "$thisDir/../../support/shunit2" + diff --git a/test/lib/git/status_shortcuts_test.sh b/test/lib/git/status_shortcuts_test.sh new file mode 100755 index 0000000..152754d --- /dev/null +++ b/test/lib/git/status_shortcuts_test.sh @@ -0,0 +1,255 @@ +#!/bin/bash +# ------------------------------------------------------------------------------ +# Git Breeze - Streamline your git workflow. +# Copyright 2011 Nathan Broadbent (http://madebynathan.com). All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# ------------------------------------------------------------------------------ +# +# Unit tests for git shell scripts + +thisDir="$( cd -P "$( dirname "$0" )" && pwd )" + +# Zsh compatibility +if [ -n "${ZSH_VERSION:-}" ]; then shell="zsh"; SHUNIT_PARENT=$0; setopt shwordsplit; fi + +# Load test helpers +. "$thisDir/../../support/test_helper" + +# Load functions to test +. "$thisDir/../../../lib/_shared.sh" +. "$thisDir/../../../lib/git/status_shortcuts.sh" + + +# Setup and tear down +#----------------------------------------------------------------------------- +oneTimeSetUp() { + # Test Config + git_env_char="e" + gs_max_changes="20" + ga_auto_remove="yes" + + testRepo=$(mktemp -d) +} + +oneTimeTearDown() { + rm -rf "${testRepo}" +} + +setupTestRepo() { + rm -rf "${testRepo}" + mkdir -p "$testRepo" + cd "$testRepo" + git init > /dev/null +} + + +#----------------------------------------------------------------------------- +# Unit tests +#----------------------------------------------------------------------------- + +test_git_expand_args() { + local e1="one"; local e2="two"; local e3="three"; local e4="four"; local e5="five"; local e6="six"; local e7="seven" + local error="Args not expanded correctly" + assertEquals "$error" "one three seven" "$(git_expand_args 1 3 7)" + assertEquals "$error" "one two three six" "$(git_expand_args 1..3 6)" + assertEquals "$error" "seven two three four five one" "$(git_expand_args seven 2..5 1)" +} + + +test_git_status_shortcuts() { + setupTestRepo + + silentGitCommands + + # Set up some modifications + touch deleted_file + git add deleted_file + git commit -m "Test commit" + touch new_file + touch untracked_file + git add new_file + echo "changed" > new_file + rm deleted_file + + verboseGitCommands + + # Test that groups can be filtered by passing a parameter + git_status1=$(git_status_shortcuts 1) + git_status3=$(git_status_shortcuts 3) + git_status4=$(git_status_shortcuts 4) + + # Test for presence of expected groups + assertIncludes "$git_status1" "Changes to be committed" + assertIncludes "$git_status3" "Changes not staged for commit" + assertIncludes "$git_status4" "Untracked files" + assertNotIncludes "$git_status3" "Changes to be committed" + assertNotIncludes "$git_status4" "Changes not staged for commit" + assertNotIncludes "$git_status1" "Untracked files" + assertNotIncludes "$git_status4" "Changes to be committed" + assertNotIncludes "$git_status1" "Changes not staged for commit" + assertNotIncludes "$git_status3" "Untracked files" + + # Run command in shell, load output from temp file into variable + temp_file=$(mktemp) + git_status_shortcuts > $temp_file + git_status=$(cat $temp_file | strip_colors) + + assertIncludes "$git_status" "new file: *\[1\] *new_file" || return + assertIncludes "$git_status" "deleted: *\[2\] *deleted_file" || return + assertIncludes "$git_status" "modified: *\[3\] *new_file" || return + assertIncludes "$git_status" "untracked: *\[4\] *untracked_file" || return + + # Test that shortcut env variables are set with full path + local error="Env variable was not set" + assertEquals "$error" "$testRepo/new_file" "$e1" || return + assertEquals "$error" "$testRepo/deleted_file" "$e2" || return + assertEquals "$error" "$testRepo/new_file" "$e3" || return + assertEquals "$error" "$testRepo/untracked_file" "$e4" || return +} + +test_git_status_produces_relative_paths() { + setupTestRepo + + mkdir -p dir1/sub1/subsub1 + mkdir -p dir1/sub2 + mkdir -p dir2 + touch dir1/sub1/subsub1/testfile + touch dir1/sub2/testfile + touch dir2/testfile + git add . + + git_status=$(git_status_shortcuts | strip_colors) + assertIncludes "$git_status" "dir1/sub1/subsub1/testfile" || return + + cd $testRepo/dir1 + git_status=$(git_status_shortcuts | strip_colors) + assertIncludes "$git_status" " sub1/subsub1/testfile" || return + assertIncludes "$git_status" " sub2/testfile" || return + assertIncludes "$git_status" "../dir2/testfile" || return + + cd $testRepo/dir1/sub1 + git_status=$(git_status_shortcuts | strip_colors) + assertIncludes "$git_status" " subsub1/testfile" || return + assertIncludes "$git_status" " ../sub2/testfile" || return + assertIncludes "$git_status" "../../dir2/testfile" || return + + cd $testRepo/dir1/sub1/subsub1 + git_status=$(git_status_shortcuts | strip_colors) + assertIncludes "$git_status" " testfile" || return + assertIncludes "$git_status" " ../../sub2/testfile" || return + assertIncludes "$git_status" "../../../dir2/testfile" || return +} + + +test_git_status_shortcuts_merge_conflicts() { + setupTestRepo + + silentGitCommands + + # Set up every possible merge conflict + touch both_modified both_deleted deleted_by_them deleted_by_us + echo "renamed file needs some content" > renamed_file + git add both_modified both_deleted renamed_file deleted_by_them deleted_by_us + git commit -m "First commit" + + git checkout -b conflict_branch + echo "added by branch" > both_added + echo "branch line" > both_modified + echo "deleted by us" > deleted_by_us + git rm deleted_by_them both_deleted + git mv renamed_file renamed_file_on_branch + git add both_added both_modified deleted_by_us + git commit -m "Branch commit" + + git checkout master + echo "added by master" > both_added + echo "master line" > both_modified + echo "deleted by them" > deleted_by_them + git rm deleted_by_us both_deleted + git mv renamed_file renamed_file_on_master + git add both_added both_modified deleted_by_them + git commit -m "Master commit" + + git merge conflict_branch + + verboseGitCommands + + # Test output without stripped color codes + git_status=$(git_status_shortcuts | strip_colors) + assertIncludes "$git_status" "both added: *\[[0-9]*\] *both_added" || return + assertIncludes "$git_status" "both modified: *\[[0-9]*\] *both_modified" || return + assertIncludes "$git_status" "deleted by them: *\[[0-9]*\] *deleted_by_them" || return + assertIncludes "$git_status" "deleted by us: *\[[0-9]*\] *deleted_by_us" || return + assertIncludes "$git_status" "both deleted: *\[[0-9]*\] *renamed_file" || return + assertIncludes "$git_status" "added by them: *\[[0-9]*\] *renamed_file_on_branch" || return + assertIncludes "$git_status" "added by us: *\[[0-9]*\] *renamed_file_on_master" || return +} + + +test_git_status_shortcuts_max_changes() { + setupTestRepo + + export gs_max_changes="5" + + # Add 5 untracked files + touch a b c d e + git_status=$(git_status_shortcuts | strip_colors) + for i in $(seq 1 5); do + assertIncludes "$git_status" "\[$i\]" || return + done + + # 6 untracked files is more than $gs_max_changes + touch f + git_status=$(git_status_shortcuts | strip_colors) + assertNotIncludes "$git_status" "\[[0-9]*\]" || return + + export gs_max_changes="20" +} + + +test_git_add_shorcuts() { + setupTestRepo + + touch a b c d e f g h i j + # Show git status, which sets up env variables + git_status_shortcuts > /dev/null + git_add_shorcuts 2..4 7 8 > /dev/null + git_status=$(git_status_shortcuts 1 | strip_colors) + + for c in b c d g h; do + assertIncludes "$git_status" "\[[0-9]*\] $c" || return + done +} + +test_git_commit_prompt() { + setupTestRepo + + commit_msg="\"Nathan's git commit prompt function!\"" + dbl_escaped_msg="\\\\\"Nathan's git commit prompt function\"'"'!'"'\"\\\\\"" + # Create temporary history file + HISTFILE=$(mktemp) + HISTFILESIZE=1000 + HISTSIZE=1000 + + touch a b c d + git add . > /dev/null + + # Lightly test the git commit prompt, by piping a commit message + # instead of user input. + echo "$commit_msg" | git_commit_prompt > /dev/null + + git_show_output=$(git show --oneline --name-only) + assertIncludes "$git_show_output" "$commit_msg" + + # Test that history was appended correctly. + if [[ $shell != "zsh" ]]; then history -n; fi # Reload bash history + test_history="$(history)" + assertIncludes "$test_history" "$commit_msg" + assertIncludes "$test_history" "git commit -m \"$dbl_escaped_msg\"" +} + + + +# load and run shUnit2 +. "$thisDir/../../support/shunit2" + diff --git a/test/support/shunit2 b/test/support/shunit2 new file mode 100755 index 0000000..8862ffd --- /dev/null +++ b/test/support/shunit2 @@ -0,0 +1,1048 @@ +#! /bin/sh +# $Id: shunit2 335 2011-05-01 20:10:33Z kate.ward@forestent.com $ +# vim:et:ft=sh:sts=2:sw=2 +# +# Copyright 2008 Kate Ward. All Rights Reserved. +# Released under the LGPL (GNU Lesser General Public License) +# +# shUnit2 -- Unit testing framework for Unix shell scripts. +# http://code.google.com/p/shunit2/ +# +# Author: kate.ward@forestent.com (Kate Ward) +# +# shUnit2 is a xUnit based unit test framework for Bourne shell scripts. It is +# based on the popular JUnit unit testing framework for Java. + +# return if shunit already loaded +[ -n "${SHUNIT_VERSION:-}" ] && exit 0 + +SHUNIT_VERSION='2.1.6' + +SHUNIT_TRUE=0 +SHUNIT_FALSE=1 +SHUNIT_ERROR=2 + +# enable strict mode by default +SHUNIT_STRICT=${SHUNIT_STRICT:-${SHUNIT_TRUE}} + +_shunit_warn() { echo "shunit2:WARN $@" >&2; } +_shunit_error() { echo "shunit2:ERROR $@" >&2; } +_shunit_fatal() { echo "shunit2:FATAL $@" >&2; exit ${SHUNIT_ERROR}; } + +# specific shell checks +if [ -n "${ZSH_VERSION:-}" ]; then + setopt |grep "^shwordsplit$" >/dev/null + if [ $? -ne ${SHUNIT_TRUE} ]; then + _shunit_fatal 'zsh shwordsplit option is required for proper operation' + fi + if [ -z "${SHUNIT_PARENT:-}" ]; then + _shunit_fatal "zsh does not pass \$0 through properly. please declare \ +\"SHUNIT_PARENT=\$0\" before calling shUnit2" + fi +fi + +# +# constants +# + +__SHUNIT_ASSERT_MSG_PREFIX='ASSERT:' +__SHUNIT_MODE_SOURCED='sourced' +__SHUNIT_MODE_STANDALONE='standalone' +__SHUNIT_PARENT=${SHUNIT_PARENT:-$0} + +# set the constants readonly +shunit_constants_=`set |grep '^__SHUNIT_' |cut -d= -f1` +echo "${shunit_constants_}" |grep '^Binary file' >/dev/null && \ + shunit_constants_=`set |grep -a '^__SHUNIT_' |cut -d= -f1` +for shunit_constant_ in ${shunit_constants_}; do + shunit_ro_opts_='' + case ${ZSH_VERSION:-} in + '') ;; # this isn't zsh + [123].*) ;; # early versions (1.x, 2.x, 3.x) + *) shunit_ro_opts_='-g' ;; # all later versions. declare readonly globally + esac + readonly ${shunit_ro_opts_} ${shunit_constant_} +done +unset shunit_constant_ shunit_constants_ shunit_ro_opts_ + +# variables +__shunit_lineno='' # line number of executed test +__shunit_mode=${__SHUNIT_MODE_SOURCED} # operating mode +__shunit_reportGenerated=${SHUNIT_FALSE} # is report generated +__shunit_script='' # filename of unittest script (standalone mode) +__shunit_skip=${SHUNIT_FALSE} # is skipping enabled +__shunit_suite='' # suite of tests to execute + +# counts of tests +__shunit_testSuccess=${SHUNIT_TRUE} +__shunit_testsTotal=0 +__shunit_testsPassed=0 +__shunit_testsFailed=0 + +# counts of asserts +__shunit_assertsTotal=0 +__shunit_assertsPassed=0 +__shunit_assertsFailed=0 +__shunit_assertsSkipped=0 + +# macros +_SHUNIT_LINENO_='eval __shunit_lineno=""; if [ "${1:-}" = "--lineno" ]; then [ -n "$2" ] && __shunit_lineno="[$2] "; shift 2; fi' + +#----------------------------------------------------------------------------- +# assert functions +# + +# Assert that two values are equal to one another. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertEquals() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertEquals() requires two or three arguments; $# given" + _shunit_error "1: ${1:+$1} 2: ${2:+$2} 3: ${3:+$3}${4:+ 4: $4}" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + shunit_return=${SHUNIT_TRUE} + if [ "${shunit_expected_}" = "${shunit_actual_}" ]; then + _shunit_assertPass + else + failNotEquals "${shunit_message_}" "${shunit_expected_}" "${shunit_actual_}" + shunit_return=${SHUNIT_FALSE} + fi + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${shunit_return} +} +_ASSERT_EQUALS_='eval assertEquals --lineno "${LINENO:-}"' + +# Assert that two values are not equal to one another. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotEquals() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertNotEquals() requires two or three arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + shunit_return=${SHUNIT_TRUE} + if [ "${shunit_expected_}" != "${shunit_actual_}" ]; then + _shunit_assertPass + else + failSame "${shunit_message_}" "$@" + shunit_return=${SHUNIT_FALSE} + fi + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${shunit_return} +} +_ASSERT_NOT_EQUALS_='eval assertNotEquals --lineno "${LINENO:-}"' + +# Assert that a value is null (i.e. an empty string) +# +# Args: +# message: string: failure message [optional] +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNull() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "assertNull() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + assertTrue "${shunit_message_}" "[ -z '$1' ]" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +_ASSERT_NULL_='eval assertNull --lineno "${LINENO:-}"' + +# Assert that a value is not null (i.e. a non-empty string) +# +# Args: +# message: string: failure message [optional] +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotNull() +{ + ${_SHUNIT_LINENO_} + if [ $# -gt 2 ]; then # allowing 0 arguments as $1 might actually be null + _shunit_error "assertNotNull() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_actual_=`_shunit_escapeCharactersInString "${1:-}"` + test -n "${shunit_actual_}" + assertTrue "${shunit_message_}" $? + shunit_return=$? + + unset shunit_actual_ shunit_message_ + return ${shunit_return} +} +_ASSERT_NOT_NULL_='eval assertNotNull --lineno "${LINENO:-}"' + +# Assert that two values are the same (i.e. equal to one another). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertSame() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertSame() requires two or three arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + assertEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +_ASSERT_SAME_='eval assertSame --lineno "${LINENO:-}"' + +# Assert that two values are not the same (i.e. not equal to one another). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotSame() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertNotSame() requires two or three arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_:-}$1" + shift + fi + assertNotEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +_ASSERT_NOT_SAME_='eval assertNotSame --lineno "${LINENO:-}"' + +# Assert that a value or shell test condition is true. +# +# In shell, a value of 0 is true and a non-zero value is false. Any integer +# value passed can thereby be tested. +# +# Shell supports much more complicated tests though, and a means to support +# them was needed. As such, this function tests that conditions are true or +# false through evaluation rather than just looking for a true or false. +# +# The following test will succeed: +# assertTrue 0 +# assertTrue "[ 34 -gt 23 ]" +# The folloing test will fail with a message: +# assertTrue 123 +# assertTrue "test failed" "[ -r '/non/existant/file' ]" +# +# Args: +# message: string: failure message [optional] +# condition: string: integer value or shell conditional statement +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertTrue() +{ + ${_SHUNIT_LINENO_} + if [ $# -gt 2 ]; then + _shunit_error "assertTrue() takes one two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_condition_=$1 + + # see if condition is an integer, i.e. a return value + shunit_match_=`expr "${shunit_condition_}" : '\([0-9]*\)'` + shunit_return=${SHUNIT_TRUE} + if [ -z "${shunit_condition_}" ]; then + # null condition + shunit_return=${SHUNIT_FALSE} + elif [ -n "${shunit_match_}" -a "${shunit_condition_}" = "${shunit_match_}" ] + then + # possible return value. treating 0 as true, and non-zero as false. + [ ${shunit_condition_} -ne 0 ] && shunit_return=${SHUNIT_FALSE} + else + # (hopefully) a condition + ( eval ${shunit_condition_} ) >/dev/null 2>&1 + [ $? -ne 0 ] && shunit_return=${SHUNIT_FALSE} + fi + + # record the test + if [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then + _shunit_assertPass + else + _shunit_assertFail "${shunit_message_}" + fi + + unset shunit_message_ shunit_condition_ shunit_match_ + return ${shunit_return} +} +_ASSERT_TRUE_='eval assertTrue --lineno "${LINENO:-}"' + +# Assert that a value or shell test condition is false. +# +# In shell, a value of 0 is true and a non-zero value is false. Any integer +# value passed can thereby be tested. +# +# Shell supports much more complicated tests though, and a means to support +# them was needed. As such, this function tests that conditions are true or +# false through evaluation rather than just looking for a true or false. +# +# The following test will succeed: +# assertFalse 1 +# assertFalse "[ 'apples' = 'oranges' ]" +# The folloing test will fail with a message: +# assertFalse 0 +# assertFalse "test failed" "[ 1 -eq 1 -a 2 -eq 2 ]" +# +# Args: +# message: string: failure message [optional] +# condition: string: integer value or shell conditional statement +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertFalse() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "assertFalse() quires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_condition_=$1 + + # see if condition is an integer, i.e. a return value + shunit_match_=`expr "${shunit_condition_}" : '\([0-9]*\)'` + shunit_return=${SHUNIT_TRUE} + if [ -z "${shunit_condition_}" ]; then + # null condition + shunit_return=${SHUNIT_FALSE} + elif [ -n "${shunit_match_}" -a "${shunit_condition_}" = "${shunit_match_}" ] + then + # possible return value. treating 0 as true, and non-zero as false. + [ ${shunit_condition_} -eq 0 ] && shunit_return=${SHUNIT_FALSE} + else + # (hopefully) a condition + ( eval ${shunit_condition_} ) >/dev/null 2>&1 + [ $? -eq 0 ] && shunit_return=${SHUNIT_FALSE} + fi + + # record the test + if [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then + _shunit_assertPass + else + _shunit_assertFail "${shunit_message_}" + fi + + unset shunit_message_ shunit_condition_ shunit_match_ + return ${shunit_return} +} +_ASSERT_FALSE_='eval assertFalse --lineno "${LINENO:-}"' + +#----------------------------------------------------------------------------- +# failure functions +# + +# Records a test failure. +# +# Args: +# message: string: failure message [optional] +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +fail() +{ + ${_SHUNIT_LINENO_} + if [ $# -gt 1 ]; then + _shunit_error "fail() requires zero or one arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 1 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + _shunit_assertFail "${shunit_message_}" + + unset shunit_message_ + return ${SHUNIT_FALSE} +} +_FAIL_='eval fail --lineno "${LINENO:-}"' + +# Records a test failure, stating two values were not equal. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failNotEquals() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failNotEquals() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected:<${shunit_expected_}> but was:<${shunit_actual_}>" + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${SHUNIT_FALSE} +} +_FAIL_NOT_EQUALS_='eval failNotEquals --lineno "${LINENO:-}"' + +# Records a test failure, stating two values should have been the same. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failSame() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failSame() requires two or three arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected not same" + + unset shunit_message_ + return ${SHUNIT_FALSE} +} +_FAIL_SAME_='eval failSame --lineno "${LINENO:-}"' + +# Records a test failure, stating two values were not equal. +# +# This is functionally equivalent to calling failNotEquals(). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failNotSame() +{ + ${_SHUNIT_LINENO_} + if [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failNotEquals() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + _shunit_shouldSkip && return ${SHUNIT_TRUE} + + shunit_message_=${__shunit_lineno} + if [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + failNotEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +_FAIL_NOT_SAME_='eval failNotSame --lineno "${LINENO:-}"' + +#----------------------------------------------------------------------------- +# skipping functions +# + +# Force remaining assert and fail functions to be "skipped". +# +# This function forces the remaining assert and fail functions to be "skipped", +# i.e. they will have no effect. Each function skipped will be recorded so that +# the total of asserts and fails will not be altered. +# +# Args: +# None +startSkipping() +{ + __shunit_skip=${SHUNIT_TRUE} +} + +# Resume the normal recording behavior of assert and fail calls. +# +# Args: +# None +endSkipping() +{ + __shunit_skip=${SHUNIT_FALSE} +} + +# Returns the state of assert and fail call skipping. +# +# Args: +# None +# Returns: +# boolean: (TRUE/FALSE constant) +isSkipping() +{ + return ${__shunit_skip} +} + +#----------------------------------------------------------------------------- +# suite functions +# + +# Stub. This function should contains all unit test calls to be made. +# +# DEPRECATED (as of 2.1.0) +# +# This function can be optionally overridden by the user in their test suite. +# +# If this function exists, it will be called when shunit2 is sourced. If it +# does not exist, shunit2 will search the parent script for all functions +# beginning with the word 'test', and they will be added dynamically to the +# test suite. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#suite() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Adds a function name to the list of tests schedule for execution. +# +# This function should only be called from within the suite() function. +# +# Args: +# function: string: name of a function to add to current unit test suite +suite_addTest() +{ + shunit_func_=${1:-} + + __shunit_suite="${__shunit_suite:+${__shunit_suite} }${shunit_func_}" + __shunit_testsTotal=`expr ${__shunit_testsTotal} + 1` + + unset shunit_func_ +} + +# Stub. This function will be called once before any tests are run. +# +# Common one-time environment preparation tasks shared by all tests can be +# defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#oneTimeSetUp() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Stub. This function will be called once after all tests are finished. +# +# Common one-time environment cleanup tasks shared by all tests can be defined +# here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#oneTimeTearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Stub. This function will be called before each test is run. +# +# Common environment preparation tasks shared by all tests can be defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#setUp() { :; } + +# Note: see _shunit_mktempFunc() for actual implementation +# Stub. This function will be called after each test is run. +# +# Common environment cleanup tasks shared by all tests can be defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#tearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +#------------------------------------------------------------------------------ +# internal shUnit2 functions +# + +# Create a temporary directory to store various run-time files in. +# +# This function is a cross-platform temporary directory creation tool. Not all +# OSes have the mktemp function, so one is included here. +# +# Args: +# None +# Outputs: +# string: the temporary directory that was created +_shunit_mktempDir() +{ + # try the standard mktemp function + ( exec mktemp -dqt shunit.XXXXXX 2>/dev/null ) && return + + # the standard mktemp didn't work. doing our own. + if [ -r '/dev/urandom' -a -x '/usr/bin/od' ]; then + _shunit_random_=`/usr/bin/od -vAn -N4 -tx4 "${_shunit_file_}" +#! /bin/sh +exit ${SHUNIT_TRUE} +EOF + chmod +x "${_shunit_file_}" + done + + unset _shunit_file_ +} + +# Final cleanup function to leave things as we found them. +# +# Besides removing the temporary directory, this function is in charge of the +# final exit code of the unit test. The exit code is based on how the script +# was ended (e.g. normal exit, or via Ctrl-C). +# +# Args: +# name: string: name of the trap called (specified when trap defined) +_shunit_cleanup() +{ + _shunit_name_=$1 + + case ${_shunit_name_} in + EXIT) _shunit_signal_=0 ;; + INT) _shunit_signal_=2 ;; + TERM) _shunit_signal_=15 ;; + *) + _shunit_warn "unrecognized trap value (${_shunit_name_})" + _shunit_signal_=0 + ;; + esac + + # do our work + rm -fr "${__shunit_tmpDir}" + + # exit for all non-EXIT signals + if [ ${_shunit_name_} != 'EXIT' ]; then + _shunit_warn "trapped and now handling the (${_shunit_name_}) signal" + # disable EXIT trap + trap 0 + # add 128 to signal and exit + exit `expr ${_shunit_signal_} + 128` + elif [ ${__shunit_reportGenerated} -eq ${SHUNIT_FALSE} ] ; then + _shunit_assertFail 'Unknown failure encountered running a test' + _shunit_generateReport + exit ${SHUNIT_ERROR} + fi + + unset _shunit_name_ _shunit_signal_ +} + +# The actual running of the tests happens here. +# +# Args: +# None +_shunit_execSuite() +{ + for _shunit_test_ in ${__shunit_suite}; do + __shunit_testSuccess=${SHUNIT_TRUE} + + # disable skipping + endSkipping + + # execute the per-test setup function + setUp + + # execute the test + echo "${_shunit_test_}" + eval ${_shunit_test_} + + # execute the per-test tear-down function + tearDown + + # update stats + if [ ${__shunit_testSuccess} -eq ${SHUNIT_TRUE} ]; then + __shunit_testsPassed=`expr ${__shunit_testsPassed} + 1` + else + __shunit_testsFailed=`expr ${__shunit_testsFailed} + 1` + fi + done + + unset _shunit_test_ +} + +# Generates the user friendly report with appropriate OK/FAILED message. +# +# Args: +# None +# Output: +# string: the report of successful and failed tests, as well as totals. +_shunit_generateReport() +{ + _shunit_ok_=${SHUNIT_TRUE} + + # if no exit code was provided one, determine an appropriate one + [ ${__shunit_testsFailed} -gt 0 \ + -o ${__shunit_testSuccess} -eq ${SHUNIT_FALSE} ] \ + && _shunit_ok_=${SHUNIT_FALSE} + + echo + if [ ${__shunit_testsTotal} -eq 1 ]; then + echo "Ran ${__shunit_testsTotal} test." + else + echo "Ran ${__shunit_testsTotal} tests." + fi + + _shunit_failures_='' + _shunit_skipped_='' + [ ${__shunit_assertsFailed} -gt 0 ] \ + && _shunit_failures_="failures=${__shunit_assertsFailed}" + [ ${__shunit_assertsSkipped} -gt 0 ] \ + && _shunit_skipped_="skipped=${__shunit_assertsSkipped}" + + if [ ${_shunit_ok_} -eq ${SHUNIT_TRUE} ]; then + _shunit_msg_='OK' + [ -n "${_shunit_skipped_}" ] \ + && _shunit_msg_="${_shunit_msg_} (${_shunit_skipped_})" + else + _shunit_msg_="FAILED (${_shunit_failures_}" + [ -n "${_shunit_skipped_}" ] \ + && _shunit_msg_="${_shunit_msg_},${_shunit_skipped_}" + _shunit_msg_="${_shunit_msg_})" + fi + + echo + echo ${_shunit_msg_} + __shunit_reportGenerated=${SHUNIT_TRUE} + + unset _shunit_failures_ _shunit_msg_ _shunit_ok_ _shunit_skipped_ +} + +# Test for whether a function should be skipped. +# +# Args: +# None +# Returns: +# boolean: whether the test should be skipped (TRUE/FALSE constant) +_shunit_shouldSkip() +{ + [ ${__shunit_skip} -eq ${SHUNIT_FALSE} ] && return ${SHUNIT_FALSE} + _shunit_assertSkip +} + +# Records a successful test. +# +# Args: +# None +_shunit_assertPass() +{ + __shunit_assertsPassed=`expr ${__shunit_assertsPassed} + 1` + __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` +} + +# Records a test failure. +# +# Args: +# message: string: failure message to provide user +_shunit_assertFail() +{ + _shunit_msg_=$1 + + __shunit_testSuccess=${SHUNIT_FALSE} + __shunit_assertsFailed=`expr ${__shunit_assertsFailed} + 1` + __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` + echo "${__SHUNIT_ASSERT_MSG_PREFIX}${_shunit_msg_}" + + unset _shunit_msg_ +} + +# Records a skipped test. +# +# Args: +# None +_shunit_assertSkip() +{ + __shunit_assertsSkipped=`expr ${__shunit_assertsSkipped} + 1` + __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` +} + +# Prepare a script filename for sourcing. +# +# Args: +# script: string: path to a script to source +# Returns: +# string: filename prefixed with ./ (if necessary) +_shunit_prepForSourcing() +{ + _shunit_script_=$1 + case "${_shunit_script_}" in + /*|./*) echo "${_shunit_script_}" ;; + *) echo "./${_shunit_script_}" ;; + esac + unset _shunit_script_ +} + +# Escape a character in a string. +# +# Args: +# c: string: unescaped character +# s: string: to escape character in +# Returns: +# string: with escaped character(s) +_shunit_escapeCharInStr() +{ + [ -n "$2" ] || return # no point in doing work on an empty string + + # Note: using shorter variable names to prevent conflicts with + # _shunit_escapeCharactersInString(). + _shunit_c_=$1 + _shunit_s_=$2 + + + # escape the character + echo ''${_shunit_s_}'' |sed 's/\'${_shunit_c_}'/\\\'${_shunit_c_}'/g' + + unset _shunit_c_ _shunit_s_ +} + +# Escape a character in a string. +# +# Args: +# str: string: to escape characters in +# Returns: +# string: with escaped character(s) +_shunit_escapeCharactersInString() +{ + [ -n "$1" ] || return # no point in doing work on an empty string + + _shunit_str_=$1 + + # Note: using longer variable names to prevent conflicts with + # _shunit_escapeCharInStr(). + for _shunit_char_ in '"' '$' "'" '`'; do + _shunit_str_=`_shunit_escapeCharInStr "${_shunit_char_}" "${_shunit_str_}"` + done + + echo "${_shunit_str_}" + unset _shunit_char_ _shunit_str_ +} + +# Extract list of functions to run tests against. +# +# Args: +# script: string: name of script to extract functions from +# Returns: +# string: of function names +_shunit_extractTestFunctions() +{ + _shunit_script_=$1 + + # extract the lines with test function names, strip of anything besides the + # function name, and output everything on a single line. + _shunit_regex_='^[ ]*(function )*test[A-Za-z0-9_]* *\(\)' + egrep "${_shunit_regex_}" "${_shunit_script_}" \ + |sed 's/^[^A-Za-z0-9_]*//;s/^function //;s/\([A-Za-z0-9_]*\).*/\1/g' \ + |xargs + + unset _shunit_regex_ _shunit_script_ +} + +#------------------------------------------------------------------------------ +# main +# + +# determine the operating mode +if [ $# -eq 0 ]; then + __shunit_script=${__SHUNIT_PARENT} + __shunit_mode=${__SHUNIT_MODE_SOURCED} +else + __shunit_script=$1 + [ -r "${__shunit_script}" ] || \ + _shunit_fatal "unable to read from ${__shunit_script}" + __shunit_mode=${__SHUNIT_MODE_STANDALONE} +fi + +# create a temporary storage location +__shunit_tmpDir=`_shunit_mktempDir` + +# provide a public temporary directory for unit test scripts +# TODO(kward): document this +SHUNIT_TMPDIR="${__shunit_tmpDir}/tmp" +mkdir "${SHUNIT_TMPDIR}" + +# setup traps to clean up after ourselves +trap '_shunit_cleanup EXIT' 0 +trap '_shunit_cleanup INT' 2 +trap '_shunit_cleanup TERM' 15 + +# create phantom functions to work around issues with Cygwin +_shunit_mktempFunc +PATH="${__shunit_tmpDir}:${PATH}" + +# make sure phantom functions are executable. this will bite if /tmp (or the +# current $TMPDIR) points to a path on a partition that was mounted with the +# 'noexec' option. the noexec command was created with _shunit_mktempFunc(). +noexec 2>/dev/null || _shunit_fatal \ + 'please declare TMPDIR with path on partition with exec permission' + +# we must manually source the tests in standalone mode +if [ "${__shunit_mode}" = "${__SHUNIT_MODE_STANDALONE}" ]; then + . "`_shunit_prepForSourcing \"${__shunit_script}\"`" +fi + +# execute the oneTimeSetUp function (if it exists) +oneTimeSetUp + +# execute the suite function defined in the parent test script +# deprecated as of 2.1.0 +suite + +# if no suite function was defined, dynamically build a list of functions +if [ -z "${__shunit_suite}" ]; then + shunit_funcs_=`_shunit_extractTestFunctions "${__shunit_script}"` + for shunit_func_ in ${shunit_funcs_}; do + suite_addTest ${shunit_func_} + done +fi +unset shunit_func_ shunit_funcs_ + +# execute the tests +_shunit_execSuite + +# execute the oneTimeTearDown function (if it exists) +oneTimeTearDown + +# generate the report +_shunit_generateReport + +# that's it folks +[ ${__shunit_testsFailed} -eq 0 ] +exit $? diff --git a/test/support/test_helper b/test/support/test_helper new file mode 100644 index 0000000..a6cc2e6 --- /dev/null +++ b/test/support/test_helper @@ -0,0 +1,38 @@ +orig_cwd="$PWD" + +# +# Test helpers +#----------------------------------------------------------------------------- + +# Strip color codes from a string +strip_colors() { + sed -r "s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g" +} + +# Print space separated tab completion options +tab_completions(){ echo "${COMPREPLY[@]}"; } + +# Silence git commands +silentGitCommands() { + git() { /usr/bin/env git "$@" > /dev/null 2>&1; } +} +# Cancel silent git commands +verboseGitCommands() { + unset -f git +} + + +# Asserts +#----------------------------------------------------------------------------- + +# assert $1 contains $2 +assertIncludes() { + result=1; if echo "$1" | grep -Pq "$2"; then result=0; fi + assertTrue "'$1' should have contained '$2'" $result +} +# assert $1 does not contain $2 +assertNotIncludes() { + result=1; if echo "$1" | grep -Pq "$2"; then result=0; fi + assertFalse "'$1' should not have contained '$2'" $result +} +