Exploring the inner-workings of custom Git commands
Git allows creating custom commands, but this feature is undocumented. I have known about Git aliases for a while now, but custom commands go a little beyond what aliases can do.1
I recently encountered Git custom commands while looking through
matze/git-utils on GitHub, which provides
git pick
as a custom command presenting an interactive display for
cherry-picking. Curious, I started looking into how Git knew to run the
git-pick
binary when running git pick
.
Searching online for “Git custom commands” will pull up several webpages that explain how to make a custom Git command. These were helpful for me in learning how to make my own custom command, but they left me wondering how it worked and where it was documented, if anywhere. According to this Stack Overflow question, there is no documentation for custom commands besides the source code. I find Git custom commands useful, and think they should be more well known, so I’m writing this post to both increase the exposure of this feature of Git, and to fill in the gaps on how it works.
If my search engine skills have failed me and this is in fact documented somewhere outside the source code, I would love to know!
How Git runs commands
In order to better understand how Git custom commands work, this section contains a detailed look at how Git runs commands. If you would rather just learn how to make a custom command, skip ahead.
The core of Git’s command processing is in static int
run_argv()
in
git.c
.
To focus on the code that matters most, I have replicated a sweetened
condensed version below, with most error and edge-case handling omitted, and a
few rewritten lines for clarity. See the full source for more details.
static int run_argv(int *argcp, const char ***argv)
{
/* tracks if aliases have been expanded */
int done_alias = 0;
while (1) {
if (!done_alias) {
/* if not an alias, try running a builtin command */
handle_builtin(*argcp, *argv);
}
else if (get_builtin(**argv)) {
/* otherwise run the aliased command in a subprocess */
run_command(*argv);
}
/* not a builtin, try an external (custom) command */
execv_dashed_external(*argv);
/* omitted code for expanding aliases */
if (!handle_alias(argcp, argv)) {
/* no alias to expand, invalid command */
break;
}
/* alias found, try again */
done_alias = 1;
}
return done_alias;
}
I have an alias git st
to run git status
, and we will use that as a working
example when walking through this code. Here is what happens when I run git
st
.
Because no alias has been expanded yet, handle_builtin()
will be called.
Inside this function Git searches through the registered builtin commands stored
in struct cmd_struct commands[]
for a matching name. The commands
array
stores the name of the command like log
or status
, a function pointer to the
command, and various option flags for the command. If a match is found, the
function pointer is executed in static int
run_builtin()
,
passing in any arguments to the command. In our case, st
isn’t recognized as a
builtin command, so handle_builtin()
will return without calling exit()
,
allowing the remainder of run_argv()
to continue.
If no matching builtin command is found, the
execv_dashed_external()
function is run. This is where external or custom commands are executed. The
name of the command is appended to the string git-
and a subprocess is started
with a call to execve()
in a forked child. Here an attempt to execute git-st
will fail and continue onto alias expansion.
If no external command is found, Git attempts to expand any aliases. After a
successful alias expansion the while loop restarts and Git attempts to process
the expanded command. Because of side effects of expanding aliases, it is no
longer safe to invoke builtin functions via the function pointers.2 For
builtin commands of expanded aliases, Git will spawn a subprocess to handle the
command. Here st
will expand to status
. This is recognized as a builtin
command, and a subprocess will be spawned to run git status
.
As a recap, when running git <name>
:
- Git first attempts to run the builtin command name.
- If the builtin was not found, Git attempts to run the external command “git-name”.
- If no external command was found, aliases are expanded and the process repeats.
- Both external commands and expanded aliases are run as subprocesses.
This process can be seen by setting the GIT_TRACE
environment variable to
true.
$ GIT_TRACE=true git status
19:56:51.952359 git.c:458 trace: built-in: git status
...
With the full command, Git simply calls the builtin function. Here is the output
from running my git st
alias.
$ GIT_TRACE=true git st
19:57:46.125010 git.c:745 trace: exec: git-st
19:57:46.125057 run-command.c:654 trace: run_command: git-st
19:57:46.125216 git.c:396 trace: alias expansion: st => status
19:57:46.125227 git.c:806 trace: exec: git status
19:57:46.125232 run-command.c:654 trace: run_command: git status
19:57:46.126791 git.c:458 trace: built-in: git status
...
With an alias, an attempt to exec the nonexistent git-st
command is made. Then
“st” is expanded to “status” and git status
is made with an execve()
call.
The final log line is output from the subprocess, reflecting the same output as
running git status
in the previous example.
I found this process very interesting, and it helps to understand how Git handles custom commands before writing your own.
Writing a custom Git command
A custom Git command is an executable on your PATH that starts with git-
.
That’s it. So long as the file can be located and is executable, the contents of
the file do not matter, nor does the programming language.
The example that first taught me about custom commands
(matze/git-utils) is written in Rust. As
an example I will recreate the basics of matze’s git pick
command for cherry
picking, but written as a fish shell script instead.
Here is my final script, saved as ~/.local/bin/git-pick
.
#!/usr/bin/env fish
if test -z "$argv"
echo "usage: git pick [branch]"
exit 1
end
set hash (git log -n 100 --format=reference $argv | zf --plain --keep-order | cut -f1 -d" ")
if test -n "$hash"
git cherry-pick $hash
end
This script pipes the output of git log
to my zf fuzzy
finder to select a hash. If a hash was
selected, git cherry-pick
is executed. With this git-pick
script on my PATH,
running git pick
will execute this script. Here’s a short demo:
I also wrote fish completions for git pick
so I can get branch
suggestions on tab
.
I won’t go into more detail because there are many other articles that sufficiently cover this process. Please refer to those or others for more information!
Thoughts
Arguably Git custom commands aren’t the most effective solution. I could instead
rename my git-pick
command to pick
and have less to type. Or I could just as
easily run git-pick
instead of git pick
to get the same effect. It is
nevertheless satisfying to easily make my own commands that feel like they are
part of Git. Additionally, if I create a git-pick
man page, running git pick
--help
will show the man page for help.
On the other hand, Git does provide support for external commands. This isn’t just a side effect, it seems to be intentional based on the function names in the code. Overall, I appreciate that Git offers this feature. It allows extension of the core Git tools in a way that feels native, regardless of the actual utility of the final result.
-
As I discuss later, a Git command isn’t that special, just a way to run an executable on your path. Also, aliases starting with a
!
will be run as a shell command which is similar to custom commands.↩︎︎ -
This information is from a comment in
run_argv()
. I’m not exactly sure why this would cause issues.↩︎︎