Improved git-diff(1)

2016-07-17

Motivation

Like most people, I use git daily. For many years, I used to have these two aliases:

alias gdf="git diff"
alias gdfc="git diff --cached"

Last year I started working professionally as a developer and I began to work on many different repos at the same time. Oftentimes I would do either a git diff or git diff --cached, then come back to it 10 minutes later but then forget whether the diff had a --cached flag or not. I needed to script some more git helpers functions!

Implementation

I created a new shell function called gdf to replace the two aliases above. It works by first showing you the git diff output, then the git diff --cached output. For both outputs, a vertical colored “ribbon” is printed on the left margin to denote whether it’s the working tree (git diff) or index (git diff --cached aka “staging area”). The name of the repo is prepended/appended to the output as well to further disambiguate it. Here are the functions:

#!/usr/bin/env zsh

gdf()
{
	not_git_repo && return

	local c_blue="\x1b[1;34m"
	local c_green="\x1b[1;32m"
	local c_magenta="\x1b[1;35m"
	local ce="\x1b[0m"

	local git_repo=$(git_find_repo)
	local tag="    ${c_blue}-- $git_repo --${ce}\n"
	local git_diff=$(git diff --color=always)
	local git_diff_c=$(git diff --cached --color=always)

	if [[ -z "$git_diff" && -z "$git_diff_c" ]]; then
		printf "${c_green}NO CHANGES!$ce\n\n"
		gst
	else
		local msg=""
		if [[ -n "$git_diff" ]]; then
			# The sed '/^$/d' below is to remove the extra trailing whitespace
			# line that seems to get added into `git diff' but not `git diff
			# --cached'.
			msg="$tag"
			msg+=$(vertical_label \
				"git diff --color=always | sed '/^$/d'" \
				"TREE ------------------------ " \
				"$c_green")
			msg+="\n$tag"
			printf $msg | less
		fi

		if [[ -n "$git_diff_c" ]]; then
			msg="$tag"
			msg+=$(vertical_label \
				"git diff --cached --color=always" \
				"INDEX ------------------------ " \
				"$c_magenta")
			msg+="\n$tag"
			printf $msg | less
		fi

		gst
	fi
}
#!/usr/bin/env zsh

vertical_label()
{
	c=$3
	ce="\x1b[0m"
	label=$2
	i=1
	# The `s/^/x/' marks each line's beginning with a non-whitespace character
	# `x' so that when we pipe it to the `read' zsh builtin, we read all leading
	# indentation as well (otherwise we lose it). The `s/\t/ /g' standardizes
	# all tab characters to four spaces; this is purely for visual aesthetics.
	eval $1 | sed  's/^/x/ ; s/\t/    /g ; s/%/%%%%/g' | while read -r line; do
		case $label[$i] in
		" ") printf "  " ;;
		"-") printf " $c\u2503$ce" ;;
		*) printf " $c$label[$i]$ce" ;;
		esac
		line_without_x=$line[2,-1]
		printf "  ${line_without_x//\\/\\\\\\\\}\n"
		((i+=1))
		if (( i > $#label )); then
			i=1
		fi
	done
}
#!/usr/bin/env zsh

git_find_repo()
{
	while [[ ! -d .git ]]; do
		cd ..
		if [[ $PWD == / ]]; then
			echo "error: .git folder not found"
			return
		fi
	done
	echo ${PWD##*/}
}

I use Zsh as my shell, so I wrote the above in Zsh. I simply drop these files inside my autoloaded directory, which is defined like this:

fpath=(~/.zsh/func $fpath) # add ~/.zsh/func to $fpath
autoload -U ~/.zsh/func/*(:t) # load all functions in ~/.zsh/func

Here is some sample output (used in the course of writing this blog post):

   -- blog --
T  diff --git a/post/2016-07-17-git-diff-improved.org b/post/2016-07-17-git-diff-improved.org
R  index 631bcf4..9f6fbac 100644
E  --- a/post/2016-07-17-git-diff-improved.org
E  +++ b/post/2016-07-17-git-diff-improved.org
   @@ -7,7 +7,7 @@ tags: programming

┃   * Motivation

┃  -Like most people, I use git every day.
┃  +Like most people, I use git daily.
┃   For many years, I used to have these two aliases:

┃   #+begin_src shell
┃  @@ -15,9 +15,9 @@ alias gdf="git diff"
┃   alias gdfc="git diff --cached"
┃   #+end_src

┃  -Last year, I started working professionally as a developer, and I began to work on many different repos at the same time.
┃  +Last year I started working professionally as a developer and I began to work on many different repos at the same time.
┃   Oftentimes I would do either a ~git diff~ or ~git diff --cached~, then come back to it 10 minutes later but then forget whether the diff had a ~--cached~ flag or not.
┃  -So, I needed to script some more git helpers functions!
┃  +I needed to script some more git helpers functions!

┃   * Implementation

┃  @@ -41,9 +41,11 @@ autoload -U ~/.zsh/func/*(:t) # load all functions in ~/.zsh/func
┃   Here is some sample output:

┃   #+begin_src diff
   + foo
T   #+end_src
R
E   #+begin_src diff
E  +
    + bar
┃    #+end_src

┃   * Conclusion
   -- blog --
   -- blog --
I  diff --git a/post/2016-07-17-git-diff-improved.org b/post/2016-07-17-git-diff-improved.org
N  index dc2fca4..631bcf4 100644
D  --- a/post/2016-07-17-git-diff-improved.org
E  +++ b/post/2016-07-17-git-diff-improved.org
X  @@ -38,6 +38,14 @@ fpath=(~/.zsh/func $fpath) # add ~/.zsh/func to $fpath
    autoload -U ~/.zsh/func/*(:t) # load all functions in ~/.zsh/func
┃   #+end_src

┃  +Here is some sample output:
┃  +
┃  +#+begin_src diff
┃  +#+end_src
┃  +
┃  +#+begin_src diff
┃  +#+end_src
┃  +
┃   * Conclusion

┃   I've been a happy ~gdf~ user for some months now.
   -- blog --

Conclusion

I’ve been a happy gdf user for some months now. The only “downside” is that because of the vertical ribbon (and the repo name at the top/bottom), the output is no longer readable by patch (or copy-pastable into a diff-reading utility/service). But, this is a minor grievance at best as one can easily invoke the low-level git diff or git diff --cached directly to get the raw (patch-able) output.

Happy hacking!