#!/usr/bin/env bash # MIT (c) Wenxuan Zhang # This file is meant to be executed directly. If it's available on the PATH, # it can also be used as a subcommand of git, which then forwards all arguments # on to forgit. So, all of these commands will work as expected: # # `git forgit log` # `git forgit checkout_file` # `git forgit checkout_file README.md` # # This gives users the choice to set aliases inside of their git config instead # of their shell config if they prefer. # Set shell for fzf preview commands # Disable shellcheck for "which", because it suggests "command -v xxx" instead, # which is not a working replacement. # See https://github.com/koalaman/shellcheck/issues/1162 # shellcheck disable=2230 SHELL="$(which bash)" export SHELL # Get absolute forgit path FORGIT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/$(basename -- "${BASH_SOURCE[0]}") FORGIT_FZF_DEFAULT_OPTS=" $FZF_DEFAULT_OPTS --ansi --height='80%' --bind='alt-k:preview-up,alt-p:preview-up' --bind='alt-j:preview-down,alt-n:preview-down' --bind='ctrl-r:toggle-all' --bind='ctrl-s:toggle-sort' --bind='?:toggle-preview' --bind='alt-w:toggle-preview-wrap' --preview-window='right:60%' +1 $FORGIT_FZF_DEFAULT_OPTS " _forgit_warn() { printf "%b[Warn]%b %s\n" '\e[0;33m' '\e[0m' "$@" >&2; } _forgit_info() { printf "%b[Info]%b %s\n" '\e[0;32m' '\e[0m' "$@" >&2; } _forgit_inside_work_tree() { git rev-parse --is-inside-work-tree >/dev/null; } # tac is not available on OSX, tail -r is not available on Linux, so we use either of them _forgit_reverse_lines() { tac 2> /dev/null || tail -r; } _forgit_previous_commit() { # "SHA~" is invalid when the commit is the first commit, but we can use "--root" instead if [[ "$(git rev-parse "$1")" == "$(git rev-list --max-parents=0 HEAD)" ]]; then echo "--root" else echo "$1~" fi } _forgit_contains_non_flags() { while (("$#")); do case "$1" in -*) shift ;; *) return 0 ;; esac done return 1 } # optional render emoji characters (https://github.com/wfxr/emoji-cli) hash emojify &>/dev/null && _forgit_emojify='|emojify' # extract the first git sha occurring in the input and strip trailing newline _forgit_extract_sha="grep -Eo '[a-f0-9]+' | head -1 | tr -d '[:space:]'" _forgit_pager=${FORGIT_PAGER:-$(git config core.pager || echo 'cat')} _forgit_show_pager=${FORGIT_SHOW_PAGER:-$(git config pager.show || echo "$_forgit_pager")} _forgit_diff_pager=${FORGIT_DIFF_PAGER:-$(git config pager.diff || echo "$_forgit_pager")} _forgit_ignore_pager=${FORGIT_IGNORE_PAGER:-$(hash bat &>/dev/null && echo 'bat -l gitignore --color=always' || echo 'cat')} _forgit_blame_pager=${FORGIT_BLAME_PAGER:-$(git config pager.blame || echo "$_forgit_pager")} _forgit_enter_pager=${FORGIT_ENTER_PAGER:-"LESS='-r' less"} _forgit_log_format=${FORGIT_LOG_FORMAT:-%C(auto)%h%d %s %C(black)%C(bold)%cr%Creset} _forgit_log_preview_options="--graph --pretty=format:'$_forgit_log_format' --color=always --abbrev-commit --date=relative" _forgit_fullscreen_context=${FORGIT_FULLSCREEN_CONTEXT:-10} _forgit_preview_context=${FORGIT_PREVIEW_CONTEXT:-3} _forgit_is_file_tracked="(git ls-files {} --error-unmatch) &> /dev/null" # git commit viewer _forgit_log() { _forgit_inside_work_tree || return 1 local opts graph files log_format preview_cmd enter_cmd files=$(sed -nE 's/.*-- (.*)/\1/p' <<< "$*") # extract files parameters for `git show` command preview_cmd="echo {} | $_forgit_extract_sha | xargs -I% git show --color=always -U$_forgit_preview_context % -- $files | $_forgit_show_pager" enter_cmd="echo {} | $_forgit_extract_sha | xargs -I% ${FORGIT} diff %^! $files" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --bind=\"enter:execute($enter_cmd)\" --bind=\"ctrl-y:execute-silent(echo {} | $_forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy})\" --preview=\"$preview_cmd\" $FORGIT_LOG_FZF_OPTS " graph=--graph [[ $FORGIT_LOG_GRAPH_ENABLE == false ]] && graph= log_format=${FORGIT_GLO_FORMAT:-$_forgit_log_format} eval "git log $graph --color=always --format='$log_format' $FORGIT_LOG_GIT_OPTS $* $_forgit_emojify" | FZF_DEFAULT_OPTS="$opts" fzf fzf_exit_code=$? # exit successfully on 130 (ctrl-c/esc) [[ $fzf_exit_code == 130 ]] && return 0 return $fzf_exit_code } # git diff viewer _forgit_diff() { _forgit_inside_work_tree || return 1 local files opts commits repo get_files preview_cmd enter_cmd [[ $# -ne 0 ]] && { if git rev-parse "$1" -- &>/dev/null ; then if [[ $# -gt 1 ]] && git rev-parse "$2" -- &>/dev/null; then commits="$1 $2" && files=("${@:3}") else commits="$1" && files=("${@:2}") fi else files=("$@") fi } repo="$(git rev-parse --show-toplevel)" # Construct a null-terminated list of the filenames # The input looks like one of these lines: # [R100] file -> another file # [A] file with spaces # [D] oldfile # And we transform it to this representation for further usage with "xargs -0": # file\0another file\0 # file with spaces\0 # oldfile\0 # We have to do a two-step sed -> tr pipe because OSX's sed implementation does # not support the null-character directly. get_files="echo {} | sed 's/.*] *//' | sed 's/ -> /\\\n/' | tr '\\\n' '\\\0'" # Similar to the line above, but only gets a single file from a single line # Gets the new name of renamed files get_file="echo {} | sed 's/.*] *//' | sed 's/.*-> //'" # Git stashes are named "stash@{x}", which contains the fzf placeholder "{x}". # In order to support passing stashes as arguments to _forgit_diff, we have to # prevent fzf from interpreting this substring by escaping the opening bracket. # The string is evaluated a few subsequent times, so we need multiple escapes. escaped_commits=${commits//\{/\\\\\{} git_diff="git diff --color=always $FORGIT_DIFF_GIT_OPTS $escaped_commits" preview_cmd="cd '$repo' && $get_files | xargs -0 $git_diff -U$_forgit_preview_context -- | $_forgit_diff_pager" enter_cmd="cd '$repo' && $get_files | xargs -0 $git_diff -U$_forgit_fullscreen_context -- | $_forgit_diff_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS +m -0 --bind=\"enter:execute($enter_cmd | $_forgit_enter_pager)\" --preview=\"$preview_cmd\" --bind=\"alt-e:execute-silent($EDITOR \\\"\$\($get_file)\\\" >/dev/tty \" " eval "git diff --name-status $FORGIT_DIFF_GIT_OPTS $commits -- ${files[*]} | sed -E 's/^([[:alnum:]]+)[[:space:]]+(.*)$/[\1] \2/'" | sed 's/ / -> /2' | expand -t 8 | FZF_DEFAULT_OPTS="$opts" fzf fzf_exit_code=$? # exit successfully on 130 (ctrl-c/esc) [[ $fzf_exit_code == 130 ]] && return 0 return $fzf_exit_code } # git add selector _forgit_add() { _forgit_inside_work_tree || return 1 local git_add changed unmerged untracked files opts preview extract git_add="git add $FORGIT_ADD_GIT_OPTS" # Add files if passed as arguments [[ $# -ne 0 ]] && { $git_add "$@" && git status -su; return $?; } changed=$(git config --get-color color.status.changed red) unmerged=$(git config --get-color color.status.unmerged red) untracked=$(git config --get-color color.status.untracked red) # NOTE: paths listed by 'git status -su' mixed with quoted and unquoted style # remove indicators | remove original path for rename case | remove surrounding quotes extract=" sed 's/^.*] //' | sed 's/.* -> //' | sed -e 's/^\\\"//' -e 's/\\\"\$//'" preview=" file=\$(echo {} | $extract) if (git status -s -- \\\"\$file\\\" | grep '^??') &>/dev/null; then # diff with /dev/null for untracked files git diff --color=always --no-index -- /dev/null \\\"\$file\\\" | $_forgit_diff_pager | sed '2 s/added:/untracked:/' else git diff --color=always -- \\\"\$file\\\" | $_forgit_diff_pager fi" opts=" $FORGIT_FZF_DEFAULT_OPTS -0 -m --nth 2..,.. --preview=\"$preview\" --bind=\"alt-e:execute-silent($EDITOR \\\"\$\(echo {} | $extract\)\\\" >/dev/tty newest when you multiselect # The instances of "cut", "nl" and "sort" all serve this purpose # Please see https://github.com/wfxr/forgit/issues/253 for more details preview="echo {} | cut -f2- | $_forgit_extract_sha | xargs -I% git show --color=always % | $_forgit_show_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS --preview=\"$preview\" --multi --ansi --with-nth 2.. -0 --tiebreak=index $FORGIT_CHERRY_PICK_FZF_OPTS " # Note: do not add any pipe after the fzf call here, otherwise the fzf_exitval is not propagated properly. # Any eventual post processing can be done afterwards when the "commits" variable is assigned below. fzf_selection=$(git log --right-only --color=always --cherry-pick --oneline "$base"..."$target" | nl | FZF_DEFAULT_OPTS="$opts" fzf) fzf_exitval=$? [[ $fzf_exitval != 0 ]] && return $fzf_exitval [[ -z "$fzf_selection" ]] && return $fzf_exitval ${IFS+"false"} && unset old_IFS || old_IFS="$IFS" IFS=$'\n' # shellcheck disable=2207 commits=($(echo "$fzf_selection" | sort --numeric-sort --key=1 | cut -f2 | cut -d' ' -f1 | _forgit_reverse_lines)) ${old_IFS+"false"} && unset IFS || IFS="$old_IFS" [ ${#commits[@]} -eq 0 ] && return 1 $git_cherry_pick "${commits[@]}" } _forgit_cherry_pick_from_branch() { _forgit_inside_work_tree || return 1 local cmd preview opts branch exitval input_branch args base base=$(git branch --show-current) [[ -z "$base" ]] && echo "Current commit is not on a branch." && return 1 args=("$@") if [[ $# -ne 0 ]]; then input_branch=${args[0]} fi cmd="git branch --color=always --all | LC_ALL=C sort -k1.1,1.1 -rs" preview="git log --right-only --color=always --cherry-pick --oneline $base...{1}" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --header-lines=1 --preview=\"$preview\" $FORGIT_CHERRY_PICK_FROM_BRANCH_FZF_OPTS " # loop until either the branch selector is closed or a commit to be cherry # picked has been selected from within a branch while true do if [[ -z $input_branch ]]; then branch="$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}')" else branch=$input_branch fi unset input_branch [[ -z "$branch" ]] && return 1 _forgit_cherry_pick "$branch" exitval=$? [[ $exitval != 130 ]] || [[ $# -ne 0 ]] && return $exitval done } _forgit_rebase() { _forgit_inside_work_tree || return 1 local git_rebase cmd preview opts graph files target_commit prev_commit git_rebase="git rebase -i $FORGIT_REBASE_GIT_OPTS" graph=--graph [[ $FORGIT_LOG_GRAPH_ENABLE == false ]] && graph= cmd="git log $graph --color=always --format='$_forgit_log_format' $* $_forgit_emojify" files=$(sed -nE 's/.* -- (.*)/\1/p' <<< "$*") # extract files parameters for `git show` command preview="echo {} | $_forgit_extract_sha | xargs -I% git show --color=always % -- $files | $_forgit_show_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --bind=\"ctrl-y:execute-silent(echo {} | $_forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy})\" --preview=\"$preview\" $FORGIT_REBASE_FZF_OPTS " target_commit=$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf | eval "$_forgit_extract_sha") if [[ -n "$target_commit" ]]; then prev_commit=$(_forgit_previous_commit "$target_commit") $git_rebase "$prev_commit" fi } _forgit_fixup() { _forgit_inside_work_tree || return 1 git diff --cached --quiet && echo 'Nothing to fixup: there are no staged changes.' && return 1 local git_fixup cmd preview opts graph files target_commit prev_commit git_fixup="git commit --fixup $FORGIT_FIXUP_GIT_OPTS" graph=--graph [[ $FORGIT_LOG_GRAPH_ENABLE == false ]] && graph= cmd="git log $graph --color=always --format='$_forgit_log_format' $* $_forgit_emojify" files=$(sed -nE 's/.* -- (.*)/\1/p' <<< "$*") preview="echo {} | $_forgit_extract_sha | xargs -I% git show --color=always % -- $files | $_forgit_show_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --bind=\"ctrl-y:execute-silent(echo {} | $_forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy})\" --preview=\"$preview\" $FORGIT_FIXUP_FZF_OPTS " target_commit=$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf | eval "$_forgit_extract_sha") if [[ -n "$target_commit" ]] && $git_fixup "$target_commit"; then prev_commit=$(_forgit_previous_commit "$target_commit") # rebase will fail if there are unstaged changes so --autostash is needed to temporarily stash them # GIT_SEQUENCE_EDITOR=: is needed to skip the editor GIT_SEQUENCE_EDITOR=: git rebase --autostash -i --autosquash "$prev_commit" fi } # git checkout-file selector _forgit_checkout_file() { _forgit_inside_work_tree || return 1 local git_checkout cmd files opts git_checkout="git checkout $FORGIT_CHECKOUT_FILE_GIT_OPTS" [[ $# -ne 0 ]] && { $git_checkout -- "$@"; return $?; } cmd="git diff --color=always -- {} | $_forgit_diff_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS -m -0 --preview=\"$cmd\" $FORGIT_CHECKOUT_FILE_FZF_OPTS " files="$(git ls-files --modified "$(git rev-parse --show-toplevel)"| FZF_DEFAULT_OPTS="$opts" fzf)" [[ -n "$files" ]] && echo "$files" | tr '\n' '\0' | $git_checkout --pathspec-file-nul --pathspec-from-file - } # git checkout-branch selector _forgit_checkout_branch() { _forgit_inside_work_tree || return 1 # if called with arguments, check if branch exists, else create a new one if [[ $# -ne 0 ]]; then if [[ "$*" == "-" ]] || git show-branch "$@" &>/dev/null; then git switch "$@" else git switch -c "$@" fi checkout_status=$? git status --short return $checkout_status fi local git_checkout cmd preview opts branch cmd="git branch --color=always ${FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS:---all} | LC_ALL=C sort -k1.1,1.1 -rs" preview="git log {1} $_forgit_log_preview_options" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --header-lines=1 --preview=\"$preview\" $FORGIT_CHECKOUT_BRANCH_FZF_OPTS " branch="$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}')" [[ -z "$branch" ]] && return 1 git_checkout="git checkout $FORGIT_CHECKOUT_BRANCH_GIT_OPTS" # track the remote branch if possible if [[ "$branch" == "remotes/origin/"* ]]; then if git branch | grep -qw "${branch#remotes/origin/}"; then # hack to force creating a new branch which tracks the remote if a local branch already exists $git_checkout -b "track/${branch#remotes/origin/}" --track "$branch" elif ! $git_checkout --track "$branch" 2>/dev/null; then $git_checkout "$branch" fi else $git_checkout "$branch" fi } # git checkout-tag selector _forgit_checkout_tag() { _forgit_inside_work_tree || return 1 local git_checkout cmd opts preview git_checkout="git checkout $FORGIT_CHECKOUT_TAG_GIT_OPTS" [[ $# -ne 0 ]] && { $git_checkout "$@"; return $?; } cmd="git tag -l --sort=-v:refname" preview="git log {1} $_forgit_log_preview_options" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --preview=\"$preview\" $FORGIT_CHECKOUT_TAG_FZF_OPTS " tag="$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf)" [[ -z "$tag" ]] && return 1 $git_checkout "$tag" } # git checkout-commit selector _forgit_checkout_commit() { _forgit_inside_work_tree || return 1 local git_checkout cmd opts graph git_checkout="git checkout $FORGIT_CHECKOUT_COMMIT_GIT_OPTS" [[ $# -ne 0 ]] && { $git_checkout "$@"; return $?; } cmd="echo {} | $_forgit_extract_sha |xargs -I% git show --color=always % | $_forgit_show_pager" opts=" $FORGIT_FZF_DEFAULT_OPTS +s +m --tiebreak=index --bind=\"ctrl-y:execute-silent(echo {} | $_forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy})\" --preview=\"$cmd\" $FORGIT_CHECKOUT_COMMIT_FZF_OPTS " graph=--graph [[ $FORGIT_LOG_GRAPH_ENABLE == false ]] && graph= # shellcheck disable=2086 eval "git log $graph --color=always --format='$_forgit_log_format' $_forgit_emojify" | FZF_DEFAULT_OPTS="$opts" fzf | eval "$_forgit_extract_sha" | xargs -I% $git_checkout % -- } _forgit_branch_delete() { _forgit_inside_work_tree || return 1 local git_branch preview opts cmd branches git_branch="git branch $FORGIT_BRANCH_DELETE_GIT_OPTS" [[ $# -ne 0 ]] && { $git_branch -D "$@"; return $?; } preview="git log {1} $_forgit_log_preview_options" opts=" $FORGIT_FZF_DEFAULT_OPTS +s --multi --tiebreak=index --header-lines=1 --preview=\"$preview\" $FORGIT_BRANCH_DELETE_FZF_OPTS " cmd="git branch --color=always | LC_ALL=C sort -k1.1,1.1 -rs" branches=$(eval "$cmd" | FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}') # shellcheck disable=2086 echo -n "$branches" | tr '\n' '\0' | xargs -I{} -0 $git_branch -D {} } # git revert-commit selector _forgit_revert_commit() { _forgit_inside_work_tree || return 1 local git_revert cmd opts files preview commits IFS git_revert="git revert $FORGIT_REVERT_COMMIT_GIT_OPTS" [[ $# -ne 0 ]] && { $git_revert "$@"; return $?; } cmd="git log --graph --color=always --format='$_forgit_log_format' $* $_forgit_emojify" opts=" $FORGIT_FZF_DEFAULT_OPTS +s --tiebreak=index --ansi --with-nth 2.. $FORGIT_REVERT_COMMIT_FZF_OPTS " # in this function, we do something interesting to maintain proper ordering as it's assumed # you generally want to revert newest->oldest when you multiselect # The instances of "cut", "nl" and "sort" all serve this purpose # Please see https://github.com/wfxr/forgit/issues/253 for more details files=$(sed -nE 's/.* -- (.*)/\1/p' <<< "$*") # extract files parameters for `git show` command preview="echo {} | cut -f2- | $_forgit_extract_sha | xargs -I% git show --color=always % -- $files | $_forgit_show_pager" ${IFS+"false"} && unset old_IFS || old_IFS="$IFS" IFS=$'\n' # shellcheck disable=2207 commits=($(eval "$cmd" | nl | FZF_DEFAULT_OPTS="$opts" fzf --preview="$preview" -m | sort --numeric-sort --key=1 | cut -f2- | sed 's/^[^a-f^0-9]*\([a-f0-9]*\).*/\1/')) ${old_IFS+"false"} && unset IFS || IFS="$old_IFS" [ ${#commits[@]} -eq 0 ] && return 1 $git_revert "${commits[@]}" } # git blame viewer _forgit_blame() { _forgit_inside_work_tree || return 1 local git_blame opts flags preview file git_blame="git blame $FORGIT_BLAME_GIT_OPTS" _forgit_contains_non_flags "$@" && { $git_blame "$@"; return $?; } opts=" $FORGIT_FZF_DEFAULT_OPTS $FORGIT_BLAME_FZF_OPTS " flags=$(git rev-parse --flags "$@") preview=" if $_forgit_is_file_tracked; then git blame {} --date=short $FORGIT_BLAME_GIT_OPTS $flags | $_forgit_blame_pager else echo File not tracked fi " file=$(FZF_DEFAULT_OPTS="$opts" fzf --preview="$preview") [[ -z "$file" ]] && return 1 # shellcheck disable=2086 eval $git_blame "$file" "$flags" } # git ignore generator export FORGIT_GI_REPO_REMOTE=${FORGIT_GI_REPO_REMOTE:-https://github.com/dvcs/gitignore} export FORGIT_GI_REPO_LOCAL="${FORGIT_GI_REPO_LOCAL:-${XDG_CACHE_HOME:-$HOME/.cache}/forgit/gi/repos/dvcs/gitignore}" export FORGIT_GI_TEMPLATES=${FORGIT_GI_TEMPLATES:-$FORGIT_GI_REPO_LOCAL/templates} _forgit_ignore() { [ -d "$FORGIT_GI_REPO_LOCAL" ] || _forgit_ignore_update local IFS cmd args opts cmd="$_forgit_ignore_pager $FORGIT_GI_TEMPLATES/{2}{,.gitignore} 2>/dev/null" opts=" $FORGIT_FZF_DEFAULT_OPTS -m --preview-window='right:70%' --preview=\"eval $cmd\" $FORGIT_IGNORE_FZF_OPTS " ${IFS+"false"} && unset old_IFS || old_IFS="$IFS" IFS=$'\n' # shellcheck disable=SC2206,2207 args=($@) && [[ $# -eq 0 ]] && args=($(_forgit_ignore_list | nl -nrn -w4 -s' ' | FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $2}')) ${old_IFS+"false"} && unset IFS || IFS="$old_IFS" [ ${#args[@]} -eq 0 ] && return 1 # shellcheck disable=SC2068 _forgit_ignore_get ${args[@]} } _forgit_ignore_update() { if [[ -d "$FORGIT_GI_REPO_LOCAL" ]]; then _forgit_info 'Updating gitignore repo...' (cd "$FORGIT_GI_REPO_LOCAL" && git pull --no-rebase --ff) || return 1 else _forgit_info 'Initializing gitignore repo...' git clone --depth=1 "$FORGIT_GI_REPO_REMOTE" "$FORGIT_GI_REPO_LOCAL" fi } _forgit_ignore_get() { local item filename header for item in "$@"; do if filename=$(find -L "$FORGIT_GI_TEMPLATES" -type f \( -iname "${item}.gitignore" -o -iname "${item}" \) -print -quit); then [[ -z "$filename" ]] && _forgit_warn "No gitignore template found for '$item'." && continue header="${filename##*/}" && header="${header%.gitignore}" echo "### $header" && cat "$filename" && echo fi done } _forgit_ignore_list() { find "$FORGIT_GI_TEMPLATES" -print |sed -e 's#.gitignore$##' -e 's#.*/##' | sort -fu } _forgit_ignore_clean() { setopt localoptions rmstarsilent [[ -d "$FORGIT_GI_REPO_LOCAL" ]] && rm -rf "$FORGIT_GI_REPO_LOCAL" } valid_commands=( "add" "blame" "branch_delete" "checkout_branch" "checkout_commit" "checkout_file" "checkout_tag" "cherry_pick" "cherry_pick_from_branch" "clean" "diff" "fixup" "ignore" "log" "rebase" "reset_head" "revert_commit" "stash_show" "stash_push" ) cmd="$1" shift # shellcheck disable=SC2076 if [[ ! " ${valid_commands[*]} " =~ " ${cmd} " ]]; then if [[ -z "$cmd" ]]; then printf "forgit: missing command\n\n" else printf "forgit: '%s' is not a valid forgit command.\n\n" "$cmd" fi printf "The following commands are supported:\n" printf "\t%s\n" "${valid_commands[@]}" exit 1 fi _forgit_"${cmd}" "$@"