Captain is a simple, convenient, transparent opt-in approach to client- and CI-side git-hook management, with just a single, tiny, dependency-free shell script to download. Suited for sharing across a team, extensible for individuals. Supports all common git hooks (and probably more)! Works with Linux, MacOS, BSDs, probably WSL. Language-agnositic — no npm, ruby, yaml or anything to wrestle with.
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⡿⢿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀
⠀⣠⣤⣶⣶⣿⣿⣿⣿⣯⠀⠀⣽⣿⣿⣿⣿⣷⣶⣤⣄⠀
⢸⣿⣿⣿⣿⣿⣿⣿⣿⡅⠉⠉⢨⣿⣿⣿⣿⣿⣿⣿⣿⡇
⠈⠻⣿⣿⣿⣿⣿⣿⣿⣥⣴⣦⣬⣿⣿⣿⣿⣿⣿⣿⠟⠁
⠀⠀⢸⣿⡿⠿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⠿⢿⣿⡇⠀⠀
⠀⣠⣾⣿⠂⠀⠀⣤⣄⠀⠀⢰⣿⣿⣿⣿⡆⠐⣿⣷⣄⠀
⠀⣿⣿⡀⠀⠀⠈⠿⠟⠀⠀⠈⠻⣿⣿⡿⠃⠀⢀⣿⣿⠀
⠀⠘⠻⢿⣷⡀⠀⠀⠀⢀⣀⣀⠀⠀⠀⠀⢀⣾⡿⠟⠃⠀
⠀⠀⠀⠸⣿⣿⣷⣦⣾⣿⣿⣿⣿⣦⣴⣾⣿⣿⡇⠀⠀⠀ Aye, I'll be sinkin me hooks inta yer gits!
⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀
⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠛⠛⠛⠋⠉⠀⠀⠀⠀⠀
SITUATION: Captain was already set up in a repo you use, and you want to
start enabling its checks (AKA triggers). (Or you're a curmudgeon: You won't
be impacted if you do nothing; then capt
will not be invoked — but you will
miss out on the fun!)
# Install the capt command (a small zsh script)
cd ~/src # or somewhere like that where you keep clones
git clone https://github.com/MicahElliott/captain # to get all tooling
print 'path+=~/src/captain/bin' >> ~/.zshrc # or something/somewhere like that
# OR, put that ^^^ into a .envrc file and use https://github.com/direnv/direnv for your proj
# OR, for just the capt script (sufficient for some projects that don't need extra goodies):
# cd /somewhere/on/your/PATH
# wget https://raw.githubusercontent.com/MicahElliott/captain/main/capt && chmod +x capt
# Point git to the new hooks
cd your-project-root # like you always do
git config core.hooksPath .capt/hooks # THIS IS THE BIGGIE!!
# Make some project file changes, and
git commit # etc, just like always, nothing you do changed except NOW CLEAN CODE
# Captain at yer service! ...
(Note to MacOS users: If you use a git client/IDE that is not started from a
terminal, you'll need to ensure your PATH
is set to include
/path/to/captain
by editing /etc/paths
, as per
this.)
If there are any "triggers" (linters, formatters, informers, etc) being invoked that you don't have installed yet, Captain should kindly let you know more details.
OR, if you're looking to be the one to introduce Captain git-hook management to a project, read on....
Table of Contents
Short answer: Yes!!
Without a hook manager, it's challenging to have a single set of checks (linters, formatters, cleaners, etc) that all developers agree on. Also, having multiple objectives/tasks in a single hook file gets slow and ugly. Managers give you organization, concurrency, shortcut facilities, clarity, consistency, and much more. Over time, you come up with more ideas for things that can run automatically as checks, and eventually your standard unmanaged hook files get messy.
Take a single pre-commit
git-hook for example. You’ll want (for all devs on
each commit):
You can’t have all that without a manager — you end up cooking it yourself, half-baked. And Yes, you can simply set that all up in your CI (and you should), but you don’t want your devs waiting 15 minutes to see if their commit passed. Instead, you want them to wait a few seconds for all that to run locally, in parallel.
Specifically, here are some of Captain's features you don't want to have to invent, write, and/or wrap around every tool you run:
You can think of Captain as like moving your fancy CI setup into everyone’s local control. The output is reminiscent of Github Actions, but way easier to set up, runs automatically whenever you use git, and delivers the red and green a kajillion times faster.
Compared to Lefthook, Husky, and Overcommit, Captain is:
.capt/share.sh
control file with shell arrays of scripts for each hook (no yaml etc)capt
(not cluttered messes)Captain also has most of the features of other managers:
It’s worth noting that no one needs to know you’ve enlisted the Captain. You
can do all the following and put capt
to work for just yourself to start out
with. You’ll commit a .capt/
dir with some innocuous tiny files and point
your own git
config to use the Captain’s hooks instead of the pedestrian
hooks you may have in .git/hooks
.
Each developer of your code base is encouraged to install Captain (point them to the One-minute guide above), so violations can be caught before code changes go to CI.
git clone https://github.com/MicahElliott/captain
capt
.capt
script on your path
cd your-project
.capt/share.sh
control file (or copy the one below).capt/local.sh
control file for your personal
additional triggersThe capt
command is invoked with a single argument: the git-hook to run;
e.g., as capt pre-commit
; that will run all the pre-commit triggers. You can
optionally run capt
directly to see/debug output, and then have all of
git-hooks call it.
Say you want to enable some git-hooks. Here's how you would create the them, just like you may have done in the past with git. This step can be done by each developer upon cloning the project repo:
## If you want to commit the hooks to repo, and everyone sets hooksPath
hookdir=.capt/hooks
mkdir -p $hookdir
git config core.hooksPath $hookdir
## OR, use git's default location, not in repo; everyone has to do the hook creation
# hookdir=.git/hooks
## Create the standard executable git hook files
for hookfile in pre-commit prepare-commit-msg commit-msg post-commit post-checkout pre-push post-rewrite; do
echo 'capt $(basename $0) $@' > $hookdir/$hookfile
chmod +x $hookdir/$hookfile
done
Now your $hookdir
looks like this:
.capt/hooks/ # or .git/hooks/
├── commit-message
├── post-checkout
├── post-commit
├── post-rewrite
└── pre-commit
└── pre-commit-msg
└── pre-push
And each of those just contains a one-line invocation of the capt
command.
That enables git to do its default thing: next time you (or anyone) does a
git commit
, git will fire its default pre-commit
script (you just created
that) which calls capt
with git's args. Then capt
does its job of finding
the .capt/share.sh
control file (and optionally .capt/local.sh
) that you
created.
Now you can put all those trivial one-liner git-hooks into your project's repo:
echo '.capt/local.sh' >>.gitignore # discussed below
git add .capt
git commit -m 'Add capt-driven git hooks etc (PSA: install capt and set hooksPath)'
That saves all your fellow developers from having to do anything but set: git config core.hooksPath $hookdir
, and you can simply point to the
One-minute
instructions above.
It is outside Captain's scope to install all your team's trigger tools on every dev's machine. However, this repo provides an example script that should demonstrate common practice for teams, to get everyone on the same page. Basically, a project should have a script (or at least a doc) for getting all the tooling installed. It might be just a bunch of dnf/apt-get/pacman/brew commands, or it could even be an ansible file.
Now onto the simple .capt/share.sh
control file at the root of your repo
(which should also be committed), containing a set of "triggers" for each hook.
(Note that git-hooks purposes are written about
here.)
There is a tiny DSL that is used for each "trigger" in a control file.
'lint(clj|cljs): clj-kondo $CAPT_CHANGES &' # linting of files
^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^ ^ ^^^^^^^^^^^^^^^^^^
NAME FILTERS COMMAND CONCURRENCY COMMENT
Note that this syntax looks almost exactly like the standard git conventional commits DSL.
### Captain git-hook manager control file
# params: NONE
# Common hook with several triggers for linting, formatting, and running tests
pre_commit=(
'lint: clj-kondo $CAPT_CHANGES &' # linting of files being committed
'format(clj|cljc): cljfmt &' # reformat or check for poor formatting
'fixmes: git-confirm.sh' # look for/prompt on FIXMEs etc
markdownlint # built-in config with implicit filter
'test-suite: run-minimal-test-suite $CAPT_CHANGES'
)
# params: tmp-message-file-path, commit-type, sha
# Build a commit message based on branch name standardized format.
prepare_commit_msg=(
# you/TEAM-123_FIX_lang_undo-the-widget-munging => fix(lang): Undo the widget munging #123
branch2message
)
# params: tmp-message-file-path
# Validate your project state or commit message before allowing a commit to go through
commit_msg=(
'commitlint: msglint $GITARG1' # ensure log message meets standards
)
# params: NONE
# Examples: moving in large binary files that you don’t want source
# controlled, auto-generating documentation, etc
# General informative notices, no parameters
post_commit=(
"stimulate: play-post-commit-sound.sh" # happy music on successful commit
"colorize: commit-colors $(git rev-parse HEAD)" # more confirmation rewards
)
# params: command that triggered rewrite, plus stdin for list of rewrites
# Run by commands that replace commits, (amend/rebase); same uses as post-checkout, post-merge
post_rewrite=(
)
# params: NONE
# Set up your working directory properly for your project environment
post_checkout=(
"mig-alert(sql): alert-migrations-pending.zsh" # inform that action is needed
)
# Use to validate a set of ref updates before a push occurs
pre_push=(
)
# Not a git hook!
clean_up=(
'tmpclean: rm **/*.tmp'
'artclean: rm tmp/*artifact*'
)
# IDEA: maybe let user specify install recipes
installables=(
'splint(linux)' 'bbin splint'
'splint(macos)' 'brew install splint'
)
Some things to notice in that file:
somename:
"name" prefix, then the eval'd commandname
is an optional "filter": cljfmt
will only look at .clj
and .cljc
fileslint
and format
are run in parallel by being backgrounded (&
)$CAPT_CHANGES
is the convenient list of files that are part of the commit$GITARG1
is the first available param passed from git to a hook scripttest-suite
is a local script (in .capt/scripts/
) not on path
; Captain figures that out.capt/share.sh
gets put into git at your project-root and is used by all devs on the projectclean_up
hook isn't a git hook, but you can run it directly with capt
cliTODO will likely add these soon
Suppose you have even higher personal standards than the rest of your team.
E.g., you have OCD about line length. You can ensure that all of your
commits conform by creating another local-only .capt/local.sh
control file:
pre_commit=( 'line-length-pedant: check-line-length' ... other-custom-triggers... )
Then you should add .capt/local.sh
to your .gitignore
file.
You can fine-tune Captain’s behavior with several environment variables.
CAPT_VERBOSE
:: Set to 1
to enable debug modeCAPT_DISABLE
:: Set to 1
to bypass captain doing anythingCAPT_MAIN_BRANCH
:: Useful for running in CI since default will be feature branchCAPT_FILE
:: Team-shared control file containing global hooks/triggersCAPT_LOCALFILE
:: User-local personal control file each dev may have (not in git control)CAPT_HOOKSDIR
:: Defaults to .capt/hooks
, for pointing git
toCAPT_SCRIPTSDIR
:: Defaults to .capt/scripts
, for storing team-shared triggersThere are also arrrgs you can utilize from your control files:
CAPT_FILES_CHANGED
:: Array of files changed on branchGIT_ARG1
:: First arg git sends to hookGIT_ARG2
:: Second arg git sends to hookGIT_ARG3
:: Third arg git sends to hookRather than a live demo, here's an example of a pre-commit
run (doesn't
correspond to triggers shown above). This shows a couple of team-shared checks
(clj-kondo and fixmes), and then after the parrot, a single user-local
something
trigger:
(◕‿-) CAPTAIN IS OVERHAULIN. NO QUARTER!
_________
|-_ .-. _-|
| (*^*) |
|_-"|H|"-_|
(◕‿-) Loadin the gunwales: /home/mde/work/fooproj/.capt/share.sh
(◕‿-) === PRE-COMMIT ===
(◕‿-) Discoverin yer MAIN branch remotely...
(◕‿-) Main branch bein compared against: master
(◕‿-) Files changed in yer stage (10):
(◕‿-) - base/src/main/clojure/foo/core.clj
(◕‿-) - resources/sql/some-queriees.sql
(◕‿-) - ...
(◕‿-) Execution awaits!
(◕‿-) - clj-kondo
(◕‿-) - fixmes
(◕‿-) ??? CLJ-KONDO ???
(◕‿-) Files under siege: 10
maybe some output from clj-kondo, but assume all is well
(◕‿-) Ahoy! Aimin our built-in cannon with files: $CAPT_FILES_CHANGED
(◕‿-) ✓✓✓ SURVIVAL! (time: 2ms) ✓✓✓
(◕‿-) ??? FIXMES ???
(◕‿-) Ye took care of file selection yerself, or no files needin fer sayin.
(◕‿-) Ahoy! Aimin yer cannon: fixmes: git-confirm.sh
Git-Confirm: hooks.confirm.match not set, defaulting to 'TODO'
Add matches with `git config --add hooks.confirm.match "string-to-match"`
(◕‿-) ✓✓✓ SURVIVAL! (time: 12ms) ✓✓✓
(◕‿-) Ye survived the barrage. Must have been a fluke.
\
(o>
___(()___
||
(◕‿-) Next on the plank: user-local hook scripts
(◕‿-) Loadin the gunwales: /home/mde/work/cc/.capt/local.sh
(◕‿-) Execution awaits!
(◕‿-) - something
(◕‿-) ??? SOMETHING ???
(◕‿-) Ye took care of file selection yerself, or no files needin fer sayin.
(◕‿-) Ahoy! Aimin yer cannon: something: sayhi.zsh
some output from sayhi
(◕‿-) ✓✓✓ SURVIVAL! (time: 3ms) ✓✓✓
(◕‿-) Ye survived the barrage. Must have been a fluke.
(◕‿-) Show a leg!
hint: Waiting for your editor to close the file...
You can either take the plunge and clean up, separate, and move your existing
hooks into .capt/share.sh
, OR keep existing git-hooks intact, and just add this to
the bottom of each you care about:
# exiting pre-commit: .git/hooks/pre-commit
bunch of ad hoc jank
...
which capt >/dev/null && capt $(basename $0) $@
You can run any individual hook with capt
directly. This can sometimes be
useful for debugging; or convenience, in case you want to use Captain as
something of a task collector.
To run a hook:
capt pre-commit # git standard
## OR
capt my-weird-collection
Try using a new hook locally on your own for a while. Once you're confident it
does its thing well, confirm with the team that you're moving it into the
shared .capt/scripts/
dir. If this is a script that will block their commit
or build, you want to make sure everyone is aware and knows how to comply with
it.
There is a wealth of git-hooks in the wild, and of course you can come up with your own. Here is a list of themes to start with:
Here is a list of available hooks in Overcommit for inspiration.
And here is a list of common hooks that any project may want to leverage, regardless of language:
Try out notifier for github to get real-time desktop pop-up notifications about your builds completing.
Use page-break-lines for nice,
clear sectioning, turning page-breaks (^L
) into colored lines. Those lines
can also be navigated with C-x [
(prev) and C-x ]
(next). Add
magit-process-mode
to page-break-lines-modes
to make them visible in
magit-process
.
Set environment variables that capt
will read with M-x setenv
. Eg, if you
want to enable verbose logging mode, set CAPT_VEBOSE
to 1
with that.
So you have all these great hook scripts in .capt/scripts
now, but do you
also want to run them as part of your Continuous Integration? Well, it can be
done! Captain was originally conceived more as a dev-side tool, but you don't
want to reinvent a bunch of checks to run in CI too, so here is a recipe for
setting up the scripts in CI (specifically Github
Actions, but should work with other CIs
too):
zsh
(github sorely lacks it) and capt
during the CI run, ORCAPT_MAIN_BRANCH
when invoking capt
My experience is that doing (1) may add ~12 seconds to your CI run (if you don’t do some package caching, in which case it should be ~1 second). If that's fine, it could be nice to have the consistency with what you run locally. Your invocations of those scripts can look like:
# fire all the pre-commit scripts (yes, it's already committed)
run: CAPT_MAIN_BRANCH=origin/main capt integration
See the example
workflows capt.yml
file.
If you care about optimizing the amount of work the scripts do, you may need to have them be smart about file filtering (file-name extensions, which files changed in the commit, etc). In practice, the filtering often isn't too important with the CI runs, since there you might want to go ahead and run all your tests and analyzers, etc, over your whole code base anyway.
If for any reason you need to bypass Captain, set this: export CAPT_DISABLE=1
\\
(o>
//\
___V_/_____
||
||
Copyright © Micah Elliott.
Distributed under the Eclipse Public License v2.0. See LICENSE.