git-git: The World's Smallest Git Plugin
One of the things I do almost every day at my command line is type:
$ git git whatever
It’s easy to do.
Sometimes I’ll start typing a Git command, but need to look up how to complete it, and when I return to the terminal I’ve forgotten I started typing it and –
$ git git rebase
git: 'git' is not a git command. See 'git --help'.
Did you mean this?
init
Or sometimes I will be inside a gitsh shell – where the git
prefix is
implied – but still type git
out of habit1. Oops!
I’ve been putting up with this for years, grumbling to myself, “No, I did not mean init…” and a few weeks ago decided to do something about it.
The solution turned out to be hiding in the error message I had read so many
times: 'git' is not a git command
. What if it was a git command?
Git Commands
What actually happens when you run git status
? For a long time, I imagined
the git
command looked something like this:
subcommand = ARGUMENTS.first
arguments = ARGUMENTS.rest
case subcommand
when "status": git_status(arguments)
when "commit": git_commit(arguments)
# ...
else
abort("'#{subcommand}' is not a git command")
end
But Git actually takes a very different approach, motivated by the desire to make extensions feel natural to use and easy to implement.
Each Git command is implemented as a standalone command named by convention
git-COMMAND
2. This includes builtin commands like git status
. You can
see all of them by looking in the libexec/git-core
directory included with
your Git installation. The location of this directory varies between different
operating systems and installations, but you might find it in /usr/libexec
,
/usr/local/libexec
, or (if you’ve installed Git via Homebrew)
/usr/local/Cellar/git/VERSION/libexec
,
for example:
$ ls /usr/local/Cellar/git/2.9.0/libexec/git-core
git-add
# ...
git-blame
# ...
git-checkout
# ...
git-status
# ... 166 commands in total
So the git
command actually looks more like this:
subcommand = ARGUMENTS.first
arguments = ARGUMENTS.rest
if command_exists?("git-#{subcommand}")
exec("git-#{subcommand}", arguments)
else
abort("'#{subcommand}' is not a git command")
end
When Git checks if the command exists, it looks in the libexec
directory. But
it also searches for commands on your shell’s path, which is how third-party
plugins can extend git’s behavior.
git-git
Now that we know how git finds commands, we can implement our new git
subcommand. Based on the conventions outlined above, that means we need a new
command called git-git
on our path.
If you don’t already have a place you store your own terminal commands, a good
convention is in $HOME/bin
. Create the directory with mkdir -p "$HOME/bin"
,
and add export PATH="$HOME/bin:$PATH"
to your bash or zsh config so you can
run commands there without typing their full path.
Let’s create the git-git
command and make it executable:
$ touch "$HOME/bin/git-git"
$ chmod +x "$HOME/bin/git-git"
If we run git git status
we no longer see an error! But it also doesn’t do
anything yet, so let’s implement the script.
When we run git git status
, our git-git
command is run with the single
argument status
. So to run the command we intended, we can re-execute git
with the arguments to the script. Here’s what git-git
looks like:
git "$@"
"$@"
represents all of the arguments passed to the script3.
What happens now if we run git git status
?
$ git git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean
Success! It even works if we run git git git git status
.
We had to learn a good deal about how Git is implemented to get there, but in
the end it only took a single line of scripting to alleviate a daily irritation
and extend git
with custom behavior!
Check out the git-git repository on GitHub to see the final script and get instructions to install the extension for your own use.
Thanks to Gabe Berke-Williams, Adarsh Pandit, and Rachael Berecka for reading drafts of this.
-
George Brocklehurst, the creator of gitsh, pointed out that gitsh can be configured to autocorrect this mistake, which is a nice alternative. ↩
-
This is a bit of a simplification. A number of core commands, like
git-status
andgit-show
are in fact the same executable asgit
. They are (hard) links to the same command, and Git first checks$0
(the program’s name, likegit-status
) for builtin commands before looking for commands on the path. ↩ -
It’s important to use
"$@"
, instead of$*
or other argument features of bash, because it’s the only one that will pass the arguments intact. For example, if you rangit git add "filename with spaces"
, only"$@"
will correctly maintain the quotes around the filename. ↩