Easily configurable git hooks templates without bullshit
The reason of this project, is that some Git Hooks usually have no interest in being checked out AFTER a push (like with github actions). Checking the format, preventing the commit of certain information or files, etc. All this stuff should be checked locally to prevent mistake and leave no trace of it in the history.
This type of tool already exists, but it's extremely heavy and has many dependencies (like with npm, python, etc.). This is not the case here, it's just bash script that uses the default tools of a Unix like system and to be easily configurable with the git config command.
- To finish Features implementations
- A gitlab job to run unit test
- To testing supported systems
- A Documentation review
- Gnu/Linux
- Windows
- macOS (not tested)
- BSD (need to validate posix compatibility)
- Can be configured with
git configglobally or locally see configuration - Colored messages on a terminal, plain text in a pipe or an IDE log, with
TERM=dumbor with a non emptyNO_COLOR(no-color.org) - Unit testing for each hook
- emoji type support for commit message
- Check and Auto-fix commit message format
- Check and Auto signoff when missing if required
- Check and Auto pgp singing when missing if required
- Avoid duplicate commits message
- Avoid to commit binary files with auto or interactive fix
- Avoid to commit large files with auto or interactive fix
- Support of DNCT (Do Not Commit This) tags in files
- Prevent the commit of the mac ds_store ...
- Prevent pushing commit with WIP tag
- Prevent commit when previous commit is a WIP
- Require a .gitignore file to be present in the root of the repository
- Emit a warning if potential sensitive information is found in a file
- Branch naming convention
- Branch protection rules
- Refuse a force push over remote commits never fetched or integrated, as
--force-with-lease --force-if-includesdoes
These scripts are automatically used by Git according to the actions taken,
like every githooks and comforming to the githooks manual page.
You can disable MOGH hooks for a specific project with the dedicated option. Like this:
git config mogh.enabled 0Or you can skip it one time with the git --no-verify flag for push actions.
For a commit, --no-verify does not skip prepare-commit-msg (branch protection, WIP and GPG checks). To skip MOGH for one commit only, disable it for that command:
git -c mogh.enabled=0 commit ...Git runs several hooks for one event: the hook of the hooks folder, MOGH here, and every command that a config declares for this event with hook.<name>.command and hook.<name>.event. A repository runs its own scripts that way, without touching the hooks of MOGH. Git runs the declared commands in the order of the config, then the hook of the hooks folder, and gives each one the arguments and the whole stdin of the event. A declared command that fails stops the event, as a failing hook does.
To keep these commands in the repository, write them in a file at its root, .githooks here, and track it:
[hook "after-rebase"]
event = post-rewrite
command = scripts/after-rebase.sh
[hook "tests"]
event = pre-push
command = "npm test; :"Then include this file in the config of each clone, once:
git config include.path ../.githooksThe path is relative to .git/config, so ../.githooks is the file at the root of the working tree. git hook list <event> shows what runs on an event, the hook of the hooks folder included.
- Git runs a command as
sh -c '<command> "$@"'from the root of the working tree, so the arguments of the event land at the end of the command. A script reads them as$1,$2, and so on, butnpm testonpre-pushwould run asnpm test origin <url>. Ending a command with; :drops them. - In a git config file,
;and#start a comment. A command that holds one of them goes between double quotes, as above. - Including the file is what lets a repository run its commands. MOGH is linked in every repository and git runs
post-checkoutongit clone, so a repository you clone runs nothing of its own until you include its file. Once it is included, a change of this file that you pull runs without any review, as the scripts of npm do. - A command declared in your global config runs in every repository.
- If
git hook list <event>does not show your command, your git is too old to knowhook.<name>.command.
To work properly, these scripts don't require any exta dumb tools like npm, python or other crap. Requirements are probably by default on your system.
gitof course ...printfto format a stringbashthis is an sh-compatible shellgrepsearching plain-text data sets for linesfilechecking file typesedparses and transforms texttroperation of replacing or removing specific charactersgpgif you require a PGP signaturejq(only for unit testing) JSON processor
To install it, just run the following command:
curl -sL https://gitlab.com/iglou.eu/mogh/-/raw/main/tools/install.sh | bashThat clones MOGH once, at its latest version, into $XDG_DATA_HOME/mogh (~/.local/share/mogh by default). It writes there a template folder whose hooks is a link to the hooks of the install, and sets init.templatedir of your global git config to it. From then on, git init and git clone make the .git/hooks of each new repository a link to these hooks: an update of the install reaches every linked repository at once, a hook that a new version adds included.
A path of your home is written from ~ in the config, so that a global git config shared between machines stays right where the home differs.
If init.templatedir was already set, for instance by an older MOGH install, it is replaced and its folder is left as it is: the install prints where it is, remove it yourself once your repositories are migrated (see below). If MOGH is already installed, the previous install is moved to mogh.old. If mogh.old already exists, the install stops without any change: remove or move it first.
The .git/hooks of a linked repository is the hooks folder of the install. A hook written there, by you or by a tool such as git-lfs or pre-commit, lands in the install for every repository, and if the install is moved or removed, the hooks of every linked repository stop running without any message. To run a script of one repository on a hook, declare it instead, see Run your own commands on a hook.
Or you can install it manually by running something like this:
git clone https://gitlab.com/iglou.eu/mogh.git ~/.local/share/mogh
mkdir ~/.local/share/mogh/template
ln -s ~/.local/share/mogh/hooks ~/.local/share/mogh/template/hooks
git config --global init.templatedir '~/.local/share/mogh/template'
bash ~/.local/share/mogh/tools/update.shTo update it, just run the following command:
curl -sL https://gitlab.com/iglou.eu/mogh/-/raw/main/tools/update.sh | bashThat moves the install, the one the template links to, to the latest version, the highest version number. Every linked repository runs the new version from its next hook on, there is nothing to do in the repositories. If MOGH is not installed, it returns an error. The script of the install does the same: bash "${XDG_DATA_HOME:-$HOME/.local/share}/mogh/tools/update.sh".
An older MOGH install copied the hooks into each repository, where no update reaches them, and running git init again does not replace them, it only adds missing files. tools/migrate.sh replaces each copy with the link to the install. Give it the repositories to migrate:
bash "${XDG_DATA_HOME:-$HOME/.local/share}/mogh/tools/migrate.sh" path/to/repo another/repoTo list the repositories that hold a copy, then migrate them all once you have read the list:
find ~ -path '*/.git/hooks/components/config' 2>/dev/null | sed 's|/.git/hooks/components/config$||' >mogh-repos.txt
tr '\n' '\0' <mogh-repos.txt | xargs -0 bash "${XDG_DATA_HOME:-$HOME/.local/share}/mogh/tools/migrate.sh"It only touches a copy it knows as MOGH, a components/config that reads mogh.enabled. In such a copy, the MOGH files are removed, every file that sources components/config included, even renamed by a tool (pre-commit.legacy). Any other file is set aside in .git/hooks.old. Then .git/hooks becomes the link to the install. A MOGH hook you changed by hand is removed too, so read your customized hooks before running it. A hooks folder that is already a link, a repository with core.hooksPath, a repository without a copy and a repository that already has a .git/hooks.old are left as they are, so the same list can run again. A warning, on stderr and again in a summary at the end, names each repository whose files were set aside, with these files, and each one left because of core.hooksPath, a link elsewhere than to the install or no copy.
Removing the install alone leaves dead links: the hooks of the linked repositories stop running, without any message. To uninstall it, first turn the links to the install back into empty hooks folders, then remove the install and the template key. WARNING: this uses an rm -rf, check the path if you're not sure !
hooks="$(readlink "$(git config --global --type=path init.templatedir)/hooks")"
find ~ -path '*/.git/hooks' -type l 2>/dev/null | while IFS= read -r link; do
if [ "$(readlink "$link")" = "$hooks" ]; then rm "$link" && mkdir "$link"; fi
done
rm -rf "$(dirname "$hooks")"
git config --global --unset init.templatedirThe files that the migration set aside stay in .git/hooks.old, move them back into .git/hooks if you want them.
The configuration use the default git config file, so you can change it with
the git command git config. By default, the configuration is applied only to
the current git repository, but you can apply it globally with the --global
flag option. For more information, see the
documentation.
Local configuration overrides global configuration, except for lists, like
mogh.types.extra and mogh.types.extra-emoji. There use "all values for a
multi-valued key" from both configurations like an union of the two lists.
This is a config example, to full config see the next section
# add extra type support to global config
git config --global mogh.types.extra "poop"
git config --global mogh.types.extra "mock"
git config --global mogh.types.extra "break"
# add extra emoji support to global config
git config --global mogh.types.extra-emoji "π©"
git config --global mogh.types.extra-emoji "π€‘"
git config --global mogh.types.extra-emoji "π₯"
# enable the type replacement to emoji
# but only to the current repository
git config mogh.types.emoji 1Take your git config and fill it to your needs π
This is the git config representation of how the default MOGH configuration applied for hooks.
[mogh]
enabled = 1
gitignore = 1
[mogh "dnct"]
enabled = 1
regex = \\[DNCT\\]|DONOTCOMMITTHIS|DO-NOT-COMMIT-THIS|DO_NOT_COMMIT_THIS|DO NOT COMMIT THIS
[mogh "files"]
size-max = 1024
uncache-oversize = 2
uncache-dsstore = 1
uncache-binary = 2
[mogh "gpg"]
required = 1
autofix = 1
[mogh "header"]
autofix = 1
max-length = 80
prevent-duplicate = 1
[mogh "types"]
emoji = 2
[mogh "signoff"]
required = 1
autofix = 1
[mogh "wip"]
prevent-push = 1
secure-commit = 1
[mogh "branch"]
protected = 0
protected-push = 0
protected-name = "^(main|master)$"
name-regex = ""
[mogh "push"]
safe-force = 1To enable or disable MOGH hooks.
Can be set to 0 or 1. Default is 1.
To enable or disable the gitignore requirement.
Can be set to 0 or 1. Default is 1.
To enable or disable the dnct tag checker. This tag DO NOT COMMIT THIS is used to prevent the commit of sensitive data.
Can be set to 0 or 1. Default is 1.
To set the dnct tag regex. This is the regex to match the dnct tag.
Default is
\[DNCT\]|DONOTCOMMITTHIS|DO-NOT-COMMIT-THIS|DO_NOT_COMMIT_THIS|DO NOT COMMIT THIS
To enable or disable the ds_store remover. When you work with mac guys ...
Can be set to 0 or 1. Default is 1.
To enable or disable the binary file remover. This flag is used to prevent the commit of executable binaries.
Can be set to 0, 1, 2. Default is 2. 0: keep it; 1: remove from commit; 2: interactive fix.
To set the maximum file size to be committed. This flag is used to prevent the commit of large files. A file of exactly this size is accepted. The size is the one of the staged content, which is what the commit records.
Can be set to a number of KB, without a suffix: git reads
1mas 1048576, which mogh then counts as KB. Default is 1024.
To enable or disable the file size autofix.
Can be set to 0, 1, 2. Default is 2. 0: keep it; 1: remove from commit; 2: interactive fix.
With mogh.files.uncache-binary or mogh.files.uncache-oversize, the mode 1 and the answer r of the mode 2 take the staged change out of the commit and leave the file on disk.
A new file becomes untracked, and the message suggests adding it to .gitignore. A file already committed keeps its committed version.
A file kept in the commit still goes through the next checks: an executable you keep is still checked for its size.
The mode 2 asks on the terminal, and asks again after an invalid answer. An empty answer continues the commit. Without a terminal (IDE, GUI client, CI), nobody can answer, so the file is kept in the commit with a warning.
If a removal fails, the commit is aborted.
If the removals leave nothing to commit, the commit is aborted too: run git commit --allow-empty for an empty commit on purpose.
A commit aborted after some removals (answer a, failed removal, emptied commit, or another hook refusing the commit) keeps those removals: the removed changes stay unstaged until you stage them again.
A commit that replays existing commits never removes a file and never asks: the commit that concludes a git merge, a git cherry-pick or a git revert stopped by a conflict, finished with git commit or with --continue.
The .DS_Store, executable and size checks keep each flagged file with a one line warning, in mode 1 as in mode 2: these files were sorted when they were first committed and reviewed.
The DNCT check still blocks such a commit.
Git keeps a git revert --no-commit in progress until the next commit, which follows the same rule, while the commit after a git cherry-pick --no-commit is checked as a new one.
Git runs no pre-commit hook for git rebase --continue, git am --continue or git rebase --apply: the files staged while a rebase stops on an edit go into the commit unchecked, DNCT included.
To have them checked, conclude the edit with git commit --amend before git rebase --continue.
To enable or disable GPG signing requirement.
This flag will force user to use git -S, --gpg-sign
Can be set to 0 or 1. Default is 1.
To enable or disable automatic GPG signature fixing.
When signing is not enabled, this flag enables it for the repository (git config commit.gpgsign true), then stops the commit: git reads its config before the hooks, so this commit would not be signed.
Run the commit again, it is signed, like every next one.
Can be set to 0 or 1. Default is 1.
To enable or disable automatic header fixing. This flag will try to automatically fix the header of the commit. According to the conventionalcommits.org v1.
Can be set to 0 or 1. Default is 1.
To set the maximum length of the commit header. This flag is used to prevent the commit of too long header. The leading emoji and its space are not counted, so a header keeps the same length once the emoji is added.
Can be set to a number. Default is 80.
To enable or disable the duplicate header check. It refuses a commit whose header is the same as the one of the previous commit (HEAD), the only one compared. The headers are compared as recorded, whatever the emoji mode of each one, so feat: add login, β¨ feat: add login and β¨ add login are the same header. A header written by git itself (see Headers written by git) is never refused, so two git commit --fixup of the same commit pass.
To put changes in the previous commit and keep its header, run git commit --amend --no-edit, or git commit --amend with the editor: both pass, as does the reword of a git rebase -i. git commit --amend -m with the same header is refused like any duplicate.
Can be set to 0 or 1. Default is 1.
git merge, git pull, a git revert concluded after a conflict, and git commit --fixup, --squash or --fixup=amend: give the hook a header that git wrote itself. MOGH keeps it as git wrote it, without the format, length and emoji checks:
Merge branch 'x',Merge branches 'a', 'b' and 'c',Merge tag 'v1',Merge commit '<sha>'andMerge remote-tracking branch 'origin/x', with an optionalof <url>(pull) andinto <branch>Revert "<subject>"andReapply "<subject>"fixup! <subject>,squash! <subject>andamend! <subject>, thatgit rebase -i --autosquashfolds into the commit they targetMerge <url or path>, written when you pull the HEAD of a remote without naming a branch, only while the merge is in progress: typed outside a merge, it is checked like any other header
The signoff is still checked and added. During a merge, git does not give the author to its hooks, so the signoff takes the identity git uses for the commit.
The Squashed commit of the following: header that git merge --squash prepares for the next git commit is refused: that commit stays in the history, so give it a header that follows the format.
Git does not run the commit-msg hook for a git revert that ends without conflict, with or without --edit: such a revert is recorded without these checks and without the signoff. Use git revert -s when the signoff matters.
Git cleans the commit message after the commit-msg hook, and MOGH leaves that cleanup to git. The checks skip the comment lines (core.commentChar or core.commentString, # by default) and the scissors line of git commit -v with all that follows it. The rest of the message is kept as written, indentation included.
Git then cleans the message as it would without MOGH, according to commit.cleanup: by default, a commit made with -m keeps the lines starting with #, the editor drops them, and -v cuts the message at the scissors line. A merge concluded by git commit --no-edit thus keeps the # Conflicts: lines git wrote, as it does without MOGH.
To add extra commit type to the list of supported types. By default, all commit type from conventionalcommits.org v1 are available.
Default is an empty list. Can be added with
git config --global mogh.types.extra "poop"
To enable or disable the emoji type support.
The idea is to use the emoji type instead of the type name.
This is largely inspired by gitmoji.dev.
In mode 1, the emoji replaces the type, so feat: add login is recorded as β¨ add login. Such a header, an emoji of the table followed by its description, passes as it is when an amend or a reword gives it back. Modes 0 and 2 refuse a header without its type.
Can be set to 0, 1, 2. Default is 2. 0: disable; 1: enable; 2: enable but keep the type name.
To add extra emoji commit type to the list of supported types. In case you have added extra commit type to the list and whant corresponding emoji, you need to add them here. Warning, the position id in the array is used to map the commit type to emoji, so you need to keep the same order.
Default is an empty list. Can be added with
git config --global mogh.types.extra-emoji "π©"
To enable or disable the signoff requirement.
This flag will force user to use git -s, --signoff
Can be set to 0 or 1. Default is 1.
To enable or disable automatic signoff fixing.
This flag will try to automatically add the signoff to the commit.
It use git variable GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL to generate it, or the identity of git var GIT_AUTHOR_IDENT when git does not give them (a merge).
The signoff goes before the comment lines and the scissors line that end the message, where git commit -s puts it. A last paragraph of lines starting with #, kept by a commit made with -m, therefore comes after the signoff, as with git commit -s.
Can be set to 0 or 1. Default is 1.
A commit is a WIP when its header starts with the wip type, in any case, with any scope and an optional ! (wip: x, WIP(api)!: x), behind an emoji or not (π§ wip: x, emoji mode 2), or when it starts with the π§ emoji (π§ x, emoji mode 1).
A header that only mentions wip further on (fix: handle wip: state) is not a WIP. The commit and the push checks share this rule.
To enable or disable the WIP tag push prevention. This flag will prevent the push of a commit with the WIP tag. Only the commits that the push sends are checked, for every pushed branch or tag, whatever the current branch:
- an updated branch: the commits it gained since the remote one
- a new branch or tag: the commits that no remote-tracking branch of that remote has, so every commit on a first push, or when you push to a URL rather than a named remote
- a remote branch that moved to commits you never fetched: as for a new branch, then git refuses the push unless it is forced
- a deleted branch: nothing
A WIP commit that another branch of the remote already has does not stop the push of a new branch, but it probably comes by mistake, so a warning reports it.
Only the commits off the default branch of the remote (<remote>/HEAD, which a clone or a fetch sets) are looked at, so that a push never goes through the whole history: without a known default branch, there is no such warning.
Can be set to 0 or 1. Default is 1.
To enable or disable the prevention of commit when previous commit is a WIP. This flag is to keep the history clean of WIP commits.
Can be set to 0 or 1. Default is 1.
To enable or disable the branch protection. This flag will prevent the commit on a protected branch. Particularly useful when your distante repository not support branch protection.
Can be set to 0 or 1. Default is 0.
To enable or disable the branch protection push. This usefull to prevent allowing or not independently of commiting on a protected branch.
Can be set to 0 or 1. Default is 0. Note: By default, this flag is set to the value of
mogh.branch.protected.
To define a regex to match with the branches you wish to protect.
The regex is compared with the short branch name, main and not refs/heads/main, for the commit as for the push.
It is applied as you write it: anchor it with ^ and $, otherwise it also protects every branch that merely contains it (main protects domain-model).
Example: ^release/[0-9]+\.[0-9]+$ will protect all branches like release/1.0, release/2.0, etc.
Default is
^(main|master)$.
To define a regex, extended as grep -E reads it, that the name of every pushed branch must match. The regex is compared with the short name of the branch on the remote side, feat/login and not refs/heads/feat/login, and a push that creates or updates a branch with another name is refused. Deleting a remote branch is never refused, so that a badly named branch can be removed. Tags are not checked, nor are the branches that mogh.branch.protected-name matches, the convention being for work branches. As with mogh.branch.protected-name, anchor it with ^ and $. Example: ^(feat|fix|docs|refactor|test|chore)/[a-z0-9-]+$ requires names like feat/login or fix/header-length.
Default is empty, which checks no name.
To enable or disable the check of force pushes. A push that is not a fast forward replaces the tip of a remote branch, and it is refused when that tip was never part of the history of the pushed local branch: neither the pushed commit nor any past tip of the branch, as its reflog records them, contains it. Such a tip holds commits that you never fetched, or fetched without integrating them, and the push would remove them from the remote. To keep them, run git fetch, then rebase or merge them before pushing again. A force push of your own rewritten branch passes, its old tip being in the reflog.
A hook can't see the options of git push, so it can't tell --force from --force-with-lease. This check does what --force-with-lease with --force-if-includes would, whatever the option used, and it holds when a background fetch (the autofetch of an IDE) has updated the remote-tracking branch, which a lease alone accepts. Only branches are checked: a tag is moved on purpose and has no reflog. A new branch and a deleted one are never refused. A commit pushed by its object name (git push origin <sha>:main) has no reflog, only its own history counts.
Can be set to 0 or 1. Default is 1.
For contribute to this project, follow these steps:
-
Fork this repository.
-
Clone it to your local machine.
-
Link the hooks folder of this clone to its own hooks, to run them on its own commits.
.git/hooksis removed first: a link already there, to the install for instance, would get the new link inside the folder it points to.rm -rf .git/hooks && ln -s "$(pwd)/hooks" .git/hooks
-
Create a new branch with the feature, fix... name like
git checkout -b feature/feature-name. -
Commit, push and create a pull request ! π
To publish a release, push its version tag together with main, for instance git push --atomic origin main v2.0.0: the install and the update take the highest version tag, so a main that no tag carries yet would install an older version.
If you have any questions, feel free to ask. And do not forget to sign your commits.
Every hook has its own unit test (if not, I have missed it).
If you need to write a test. These are located in the tests directory and have the same name as the hook with the .test extension, you need to make it executable with chmod +x tests/commit-msg.test and cases are defined in json format.
The functions shared by several hooks, in hooks/components/common, are tested in tests/common.test.
The tools have their own tests, tests/install.test, tests/update.test and tests/migrate.test: they install from a copy of the working tree, never from the network.
To run them, you need to have jq installed. Then you can run it like every other bash script. Like:
./tests/commit-msg.testThis project is licensed under the GPL 3.0 License - see the LICENSE file for details.