Git customization: .gitconfig and the Function Command Extension Trick
Originally published
Last modified
See also: git usage
- It's actually an antipattern to commit
.gitconfig
to your dotfiles repository; it has youruser.name
anduser.email
hardcoded! - Instead, commit a script that runs
git config
commands. Let's call itgit-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-specificgit-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 -- && \
--quiet; then
! command git diff-files 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 -- && \
--quiet; then
! command git diff-files 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'