See also: git usage

  • It's actually an antipattern to commit .gitconfig to your dotfiles repository; it has your user.name and user.email hardcoded!
  • Instead, commit a script that runs git config commands. Let's call it git-config.
    • git config is the programmatic way to create entries in your .gitconfig file.
    • Run your git-config script to apply the settings.
    • Your git-config is transferable to other machines and other people.
    • Machine-specific config can then be committed to a .gitconfig.local file (see: the source .local pattern) or to a separate machine-specific git-config script.

I use a combination of the Function Command Extension Trick and a git-config script for my git customization:

Git Function Command Extension Trick

Each section here shows a self-contained, copy-pastable piece of functionality, and the Combining all the above tips section combines them together.

Prevent accidental git commit -a

Sometimes I spend time carefully adding chunks to the git stage, then I run git commit -am by habit and accidentally wipe all the work I put into preparing the stage.

This command only allows git commit -am if everything is staged or if nothing is staged, because if only some things are staged, then you probably are preparing the stage partially on purpose.

git() {
    # Prevent accidental git commit -a
    if [[ "$1" = "commit" ]] && [[ "$2" = "-a"* ]]; then
        if ! command git diff-index --cached --quiet HEAD -- && \
            ! command git diff-files --quiet; then
            echo >&2 $'\e[0;31mERROR!\e[0m Changes are already staged. Preventing git commit -a'
            echo >&2 $'\e[0;31mERROR!\e[0m Run git commit without -a or run git reset HEAD first'
            return 1
        fi
    fi
    command git "$@"
}

Truncate long lines in git grep

Occasionally, a git repository will have a file with very long lines. For example, some JS repositories commit minified code for distribution, and the minified code tends to put the entire library on 1 line.

If we augment git grep to truncate very long lines, we can safely run git grep without worrying about flooding the terminal output.

git() {
    # Truncate long lines in git grep
    if [[ "$1" = "grep" ]]; then
        if ! command -v perl &>/dev/null; then
            command git "$@"
            return
        fi
        command git -c color.ui=always "$@" | perl -pe 'my $truncate = 500; (my $blank = $_) =~ s/\e\[[^m]*m//g; if (length $blank > $truncate) {
            s/^((?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){$truncate})(?=(?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){15}).*/$1\e\[m...(truncated)/
        }'
        return
    fi
    command git "$@"
}

Show git commit subject length

It is good practice to write terse git commit subjects. This git extension will show the number of characters of the git commit subject after a successful git commit command:

git() {
    local code
    command git "$@"
    code="$?"
    if [[ "$1" = "commit" ]] && (( ! code )); then
        printf 'Commit subject length: '
        command git log -1 --format="%s" | tr -d '\n' | wc -m | awk '{print $1}'
    fi
    return "$code"
}

Combining all the above tips

# Add custom git extensions
git() {
    local code
    # Truncate long lines in git grep
    if [[ "$1" = "grep" ]]; then
        if ! command -v perl &>/dev/null; then
            command git "$@"
            return
        fi
        command git -c color.ui=always "$@" | perl -pe 'my $truncate = 500; (my $blank = $_) =~ s/\e\[[^m]*m//g; if (length $blank > $truncate) {
            s/^((?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){$truncate})(?=(?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){15}).*/$1\e\[m...(truncated)/
        }'
        return
    fi
    # Prevent accidental git commit -a
    if [[ "$1" = "commit" ]] && [[ "$2" = "-a"* ]]; then
        if ! command git diff-index --cached --quiet HEAD -- && \
            ! command git diff-files --quiet; then
            echo >&2 $'\e[0;31mERROR!\e[0m Changes are already staged. Preventing git commit -a'
            echo >&2 $'\e[0;31mERROR!\e[0m Run git commit without -a or run git reset HEAD first'
            return 1
        fi
    fi
    command git "$@"
    code="$?"
    if [[ "$1" = "commit" ]] && (( ! code )); then
        printf 'Commit subject length: '
        command git log -1 --format="%s" | tr -d '\n' | wc -m | awk '{print $1}'
    fi
    return "$code"
}

Git config

Most of these could be created as a standalone shell script, but it is easier to share git config commands than to convince/teach people to put an executable somewhere in their PATH.

I'm not including the more basic git config options here - just check my dotfiles for those.

git oldest-ancestor: find the closest common branching point

# Find the closest common branching point between 2 commits. http://stackoverflow.com/a/4991675
git config --global alias.oldest-ancestor "!bash -c '"$'git log -1 "$(diff --old-line-format= --new-line-format= <(git rev-list --first-parent "${1:-master}") <(git rev-list --first-parent "${2:-HEAD}") | head -1)"'"' -"

git bd: sort branches by date

It was hard to keep track of which branches were being actively worked on. There were hundreds of stale branches. Your team members wouldn't delete branches when they finished using them. You hated doing work that shouldn't have to be done. Your hair started to fall out due to the stress. Your family asked where it all went wrong. Despair.

But then. BUT THEN.

You reach into the depths of the void with your bash glove on. You pull out a shining tome, inscribed with git bd. The pages inside have gold leaf trim. You realize that this data can become information, making the sword cut your way at your will. You have made it easy to find the branches that matter now. You have regained your health. You have solved the problem. All the problems. Enlightenment.

# Sort branch by date
# Usage: git bd [-a] [-<line_limit>]
#     -a             include remote branches and tags
#     -<line_limit>  number of lines to tail
# Example: git bd
# Example: git bd -3
# Example: git bd -a
# Example: git bd -a -20
# Example: git bd -a20
git config --global alias.bd '!f() {
    case "$1" in
        -a) refs="--"; shift;;
        -a*) refs="--"; one="${1/-a/-}"; shift; set -- "$one" "$@";;
        *) refs="refs/heads/";;
    esac;
    git for-each-ref --color --count=1 1>/dev/null 2>&1 && color_flag=yes;
    format="--format=%(refname) %00%(committerdate:format:%s)%(taggerdate:format:%s) %(color:red)%(committerdate:relative)%(taggerdate:relative)%(color:reset)%09%00%(color:yellow)%(refname:short)%(color:reset) %00%(subject)%00 %(color:reset)%(color:dim cyan)<%(color:reset)%(color:cyan)%(authorname)%(taggername)%(color:reset)%(color:dim cyan)>%(color:reset)";
    {
        {
            [ "$color_flag" = yes ] &&
                git for-each-ref --color "$format" "$refs" ||
                git -c color.ui=always for-each-ref "$format" "$refs";
        } |
            perl -ne "print unless /^refs\/stash /";
        [ "$refs" = "--" ] &&
            git show-ref -q --verify refs/stash &&
            git log --color --walk-reflogs --format="%gd %x00%ct %C(red)%cr%C(reset)%x09%x00%C(yellow)%gd%C(reset) %x00%s%x00 %C(reset)%C(dim cyan)<%C(reset)%C(cyan)%an%C(reset)%C(dim cyan)>%C(reset)" refs/stash;
    } |
        perl -pe "s/^refs\/tags\/[^\x00]*\x00([^\x00]*)\x00([^\x00]*)/\$1(tag) \$2/ || s/^[^\x00]*\x00([^\x00]*)\x00/\$1/; s/\x00([^\x00]{0,50})([^\x00]*)\x00/\$1\x1b[1;30m\$2\x1b[0m/" |
        sort -n -k1,1 |
        cut -d" " -f2- |
        tail "${@:--n+0}";
}; f'

git l: short, elastic git log

This shows at least the last 5 commits, and up to 20 commits if there are unpushed commits, in an abbreviated format.

# Short log
# Usage: git l
# Takes the same arguments as `git log`
git config --global alias.l '!f() { : git log ;
    commit_count="$(git rev-list --count HEAD@{upstream}..HEAD 2>/dev/null || echo 2)";
    commit_count=$(( commit_count + 3 ))
    [ "$commit_count" -lt 5 ] && commit_count=5;
    [ "$commit_count" -gt 20 ] && commit_count=20;
    git --no-pager log \
        --format="%C(auto)%h %C(reset)%C(dim red)[%C(reset)%C(red)%cr%C(reset)%C(dim red)]%C(reset)%C(auto) %x02%s%x03 %C(reset)%C(dim cyan)<%C(reset)%C(cyan)%an%C(reset)%C(dim cyan)>%C(reset)%C(auto)%d%C(reset)" \
        --color --graph "-$commit_count" "$@" |
        perl -pe "
            s/ seconds? ago/s/ ||
            s/ minutes? ago/m/ ||
            s/ hours? ago/h/ ||
            s/ days? ago/d/ ||
            s/ weeks? ago/w/ ||
            s/(\d+) years?, (\d+) months? ago/\$1y\$2m/ ||
            s/ months? ago/mo/ ||
            s/ years? ago/y/;
            s/([^\x1b]\[)(.*?)]/sprintf(\"%s%21s]\",\$1,\$2)/e;
            s/\x02([^\x03]{0,50})([^\x03]*)\x03/length \$2?\$1.\"\x1b[1;30m\".\$2:\$1/e
        "; |
        less -RFX;
}; f'

git fetch-pr: fetch GitHub pull requests

# Fetch github-style pull requests
# git fetch origin +refs/pull/*/head:refs/remotes/origin/pr/*
git config --global alias.fetch-pr '!f() { git remote get-url "$1" >/dev/null 2>&1 || { printf >&2 "Usage: git fetch-pr <remote> [<pr-number>]\n"; exit 1; }; pr="$2"; [ -z "$pr" ] && pr="*"; git fetch "$1" "+refs/pull/$pr/head:refs/remotes/$1/pr/$pr"; }; f'

git fetch-mr: fetch GitLab merge requests

# Fetch gitlab-style merge requests
git config --global alias.fetch-mr '!f() { git remote get-url "$1" >/dev/null 2>&1 || { printf >&2 "Usage: git fetch-mr <remote> [<mr-number>]\n"; exit 1; }; mr="$2"; [ -z "$mr" ] && mr="*"; git fetch "$1" "+refs/merge-requests/$mr/head:refs/remotes/$1/mr/$mr"; }; f'

git grep-blame: git grep, then git blame the matched lines

# Grep with blame
git config --global alias.grep-blame "$(cat <<'EOF' | sed 's/^ *//' | tr '\n' ' '
! : git grep ; perl -e '
    my $truncate = 500;
    my ($git_maj, $git_min) = `git --version` =~ /version (\d+)\.(\d+)/;
    my $git_grep_supports_column = $git_maj == 2 && $git_min >= 19 || $git_maj > 2;
    my $full_name = (`git config --get --bool grep.fullName` eq "true\n" && $? == 0);
    my (@args, @invalid_args);
    my $last_arg_was_e = 0;
    my $index = -1;
    foreach my $arg (@ARGV) {
        $index++;
        if ($last_arg_was_e) {
            push @args, "-e", $arg;
            $last_arg_was_e = 0; next;
        }
        if ($arg eq "-e") {
            push @args, $arg if $index == $#ARGV;
            $last_arg_was_e = 1; next;
        }
        if ($arg eq "--") {
            push @args, @ARGV[$index .. $#ARGV];
            last;
        }
        if ($arg =~ /^-O|^--open-files-in-pager(?:=|$)/ ||
            $arg =~ /^(?:--no-null|--no-line-number|-h|--column|-c|--count|--heading|
                -l|--files-with-matches|--name-only|-L|--files-without-match)$/x) {
            push @invalid_args, $arg; next;
        }
        $full_name = 1 if ($arg eq "--full-name");
        $full_name = 0 if ($arg eq "--no-full-name");
        push @args, $arg;
    }
    print STDERR "Warning: Ignored invalid grep-blame flags: @invalid_args\n" if (@invalid_args);

    my $color_flag = -t STDOUT ? "--color" : "--no-color";
    unshift @args, "--no-column" if $git_grep_supports_column;

    chdir $ENV{"GIT_PREFIX"} if $ENV{"GIT_PREFIX"};
    open grep_fh, "-|", "git", "grep", "--line-number", "--null", $color_flag, @args;
    chdir $ENV{"PWD"} if $full_name;

    my $last_file; my @lines = (); my @texts = ();
    while (<grep_fh>) {
        if (/^Binary file .* matches$/) { print; next; }
        my ($file, $line, $text, $context_separator) = /^(.*?)\0(.*?)\0(.*)$|^((?:\e\[[^m]*m)?--(?:\e\[[^m]*m)?)$/;
        if (defined($context_separator)) {
            do_blame() if (@lines);
            @lines = (); @texts = ();
            print $context_separator, "\n";
            next;
        }
        if ($file eq "") { print; next; }
        if (defined($last_file) && $file ne $last_file) {
            do_blame() if (@lines);
            @lines = (); @texts = ();
        }
        $last_file = $file; push @lines, $line; push @texts, $text;
    }
    do_blame() if (@lines);
    close grep_fh;
    exit $? >> 8;
    sub do_blame {
        open blame_fh, "-|", "git", "--no-pager", "blame", (map {"-L$_,$_"} @lines), "--", $last_file;
        while (<blame_fh>) {
            /^([^\)]*\))/;
            shift @lines;
            my $text = shift @texts;
            my $out = "$1 $last_file $text";
            (my $blank_out = $out) =~ s/\e\[[^m]*m//g;
            if (length $blank_out > $truncate) {
                $out =~ s/^((?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){$truncate})(?=(?:(?:\e\[[^m]*m)+(?:.|$)|.(?:\e\[[^m]*m)*|$(*SKIP)(*FAIL)){15}).*/$1\e\[m...(truncated)/
            }
            print "$out\n";
        }
        close blame_fh;
    }
' --
EOF
)"

git subject-length: show the length of the last commit subject

# Subject length
git config --global alias.subject-length '!f() { git log -1 --format="%s" | tr -d "\n" | wc -m; }; f'

git stash-staged: stash only the staged changes

Be careful; this is destructive. Make sure you know how to get back to your current state if it messes up.

# Stash only staged changes
git config --global alias.stash-staged '!f() { : git stash ;
    staged="$(git diff --staged --unified=0)";
    unstaged="$(git diff --unified=0)";
    [ "$staged" = "" ] && return;
    [ "$unstaged" = "" ] && { git stash "$@"; return "$?"; };
    printf "This is a potentially destructive command.\nBe sure you understand it before running it.\nContinue? [y/N]: ";
    IFS= read -r cont; echo "$cont" | grep -iq "^y" || { echo "Not continuing."; return 1; };
    git reset --hard &&
        echo -E "$staged" | git apply --unidiff-zero - &&
        git stash "$@" &&
        echo -E "$unstaged" | git apply --unidiff-zero - || {
            top="$(git rev-parse --git-dir)";
            echo -E "$staged" >"$top/LAST_STAGED.diff";
            echo -E "$unstaged" >"$top/LAST_UNSTAGED.diff";
            printf "\x1b[0;31mERROR:\x1b[0m Could not stash staged.\nDiffs saved: try git apply --unidiff-zero .git/LAST_STAGED.diff .git/LAST_UNSTAGED.diff\n";
        };
}; f'

git stash-unstaged: stash only unstaged changes

Be careful; this is destructive. Make sure you know how to get back to your current state if it messes up.

# Stash only unstaged changes
git config --global alias.stash-unstaged '!f() { : git stash ;
    staged="$(git diff --staged --unified=0)";
    unstaged="$(git diff --unified=0)";
    [ "$staged" = "" ] && { git stash "$@"; return "$?"; };
    [ "$unstaged" = "" ] && return;
    printf "This is a potentially destructive command.\nBe sure you understand it before running it.\nContinue? [y/N]: ";
    IFS= read -r cont; echo "$cont" | grep -iq "^y" || { echo "Not continuing."; return 1; };
    git reset --hard && echo -E "$unstaged" |
        git apply --unidiff-zero - &&
        git stash "$@" &&
        echo -E "$staged" | git apply --unidiff-zero - || {
            top="$(git rev-parse --git-dir)";
            echo -E "$staged" >"$top/LAST_STAGED.diff";
            echo -E "$unstaged" >"$top/LAST_UNSTAGED.diff";
            printf "\x1b[0;31mERROR:\x1b[0m Could not stash unstaged.\nDiffs saved: try git apply --unidiff-zero .git/LAST_STAGED.diff .git/LAST_UNSTAGED.diff\n";
        };
}; f'