Tooling: Hooks, Submodules, Worktrees, and Friends
This chapter covers the tools around git: hooks, submodules, worktrees, aliases, configuration, and LFS.
Aliases
Git aliases are shortcuts for commands you run often. Add them to ~/.gitconfig or via git config:
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.st status
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.last "log -1 HEAD --stat"
git config --global alias.uncommit "reset --soft HEAD~1"
Now:
git lg
git uncommit
Aliases can call external commands with !:
git config --global alias.today "!git log --since='00:00' --author=\"$(git config user.name)\""
Be conservative. A repo full of custom aliases you can't remember is worse than vanilla git.
Configuration Hierarchy
Git config lives at three levels; the most specific one wins:
git config --system # /etc/gitconfig
git config --global # ~/.gitconfig
git config --local # .git/config (per-repo, the default)
Show the effective value and where it came from:
git config --show-origin user.email
# file:/Users/ada/.gitconfig ada@example.com
List everything:
git config --list --show-origin
Settings worth knowing:
git config --global core.editor "vim"
git config --global core.pager "less -FRX"
git config --global core.autocrlf input # macOS/Linux
git config --global core.autocrlf true # Windows
git config --global pull.rebase true
git config --global rerere.enabled true
git config --global fetch.prune true
git config --global diff.colorMoved "zebra"
git config --global merge.conflictStyle "zdiff3"
git config --global init.defaultBranch main
Per-repo config (for a client's repo that needs a different email, for example):
cd client-repo
git config user.email "ada@client.com"
That setting only applies in this repo; the global one still applies everywhere else.
Hooks
Hooks are shell scripts git runs at specific points in its lifecycle. They live in .git/hooks/. Every new repo has samples (.sample suffix) as starting points.
The ones teams use most:
pre-commit runs before git commit. Return non-zero to abort.
commit-msg edit or validate the commit message.
pre-push runs before git push. Abort to reject the push.
post-merge runs after a successful merge or pull.
post-checkout runs after switching branches.
A trivial pre-commit to block commits that introduce TODOs:
#!/usr/bin/env bash
# .git/hooks/pre-commit
if git diff --cached | grep -E '^\+.*TODO'; then
echo "Refusing to commit: staged changes introduce TODOs."
exit 1
fi
Make it executable:
chmod +x .git/hooks/pre-commit
Hook Managers
Hooks in .git/hooks/ are not checked into the repo. For team-wide hooks you need a manager:
- pre-commit (pre-commit.com): Python, language-agnostic hooks. The most popular.
- Husky: JavaScript/Node ecosystems.
- Lefthook: Go-based, fast, multi-stage.
Typical flow: commit a .pre-commit-config.yaml to the repo, every contributor runs pre-commit install once, and the tool wires up real hooks in .git/hooks/.
Example .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- repo: https://github.com/psf/black
rev: 24.4.0
hooks:
- id: black
Run on demand:
pre-commit run --all-files
Keep hooks fast. A five-second hook is tolerable; a 30-second one means people start running --no-verify.
Worktrees
A worktree is a second checkout of the same repo, attached to a different branch. One .git/ directory, multiple working directories.
git worktree add ../notes-hotfix hotfix/session-leak
That creates a directory ../notes-hotfix checked out at hotfix/session-leak. You can work in both directories at once; git push and git fetch operate on the shared .git/.
List and remove:
git worktree list
git worktree remove ../notes-hotfix
Great for:
- Fixing a production hotfix while your feature branch stays checked out.
- Running a long test on one branch while you code on another.
- Avoiding the "stash everything, switch, do the thing, switch back, pop stash" cycle.
Worktrees share the same .git/objects/, so they're cheap. You can have 10 of them; the only cost is disk for the checked-out files.
Submodules
A submodule is a git repo embedded inside another git repo, pinned to a specific commit.
git submodule add https://github.com/someone/lib.git vendor/lib
git commit -m "add lib as submodule"
The outer repo stores vendor/lib as a pointer to a specific commit in the submodule's repo, plus a .gitmodules file.
Cloning a repo with submodules:
git clone --recurse-submodules git@github.com:ada/notes.git
# or, after cloning:
git submodule update --init --recursive
Updating a submodule to its latest upstream:
cd vendor/lib
git pull
cd ../..
git add vendor/lib
git commit -m "bump lib to latest"
Submodules are notoriously confusing. Common issues: out-of-date pointers, dirty submodule state blocking merges, contributors forgetting --recurse-submodules.
If you have the choice, prefer a package manager (npm, cargo, pip, go mod) over submodules. Submodules are for when you genuinely need source-level access to another repo from this one, not for dependency management.
Subtree as an Alternative
git subtree merges another repo's contents directly into your tree. No pointer, no extra clone step. Simpler to use, messier to contribute fixes upstream.
git subtree add --prefix=vendor/lib https://github.com/someone/lib.git main --squash
git subtree pull --prefix=vendor/lib https://github.com/someone/lib.git main --squash
Git LFS (Large File Storage)
Git is bad at large binaries. Each version of a 50MB file is stored in full, cloning takes forever, and repos bloat fast.
Git LFS replaces large files in the repo with small pointer files; the actual contents live on an LFS server. Most hosting providers (GitHub, GitLab) include LFS storage.
Setup:
git lfs install # once per machine
git lfs track "*.psd" # track this pattern with LFS
git add .gitattributes
git add design.psd
git commit -m "add design file"
git push
.gitattributes is where LFS patterns are recorded (checked in alongside the code).
When to reach for LFS:
- Design files (
.psd,.ai,.sketch). - Large images, video, or audio.
- Binary build artifacts you must version for some reason.
- ML model checkpoints over a few MB.
When not to:
- Regular code files.
- Small configuration binaries.
- Things that would be better stored outside the repo entirely.
LFS has costs: bandwidth quotas, server-side storage, extra setup for contributors. Use it when the alternative is a 3GB clone, not because files happen to be binary.
Bisect, Briefly
Chapter 5 introduced git bisect. As a reminder:
git bisect start HEAD v1.4.0
git bisect run ./test.sh
Worth mentioning in the tooling chapter because many teams never use it. If you track down "when did X break" by reading commits, try bisect instead. It's almost always faster.
Custom Commands
Any executable named git-foo on your PATH becomes a git subcommand you can invoke as git foo.
# ~/bin/git-unpushed
#!/usr/bin/env bash
git log @{push}.. --oneline 2>/dev/null || git log --oneline --branches --not --remotes
chmod +x ~/bin/git-unpushed
git unpushed
Handy for team-specific shortcuts. Keep them in a dotfiles repo.
Common Pitfalls
Hooks that slow commits to a crawl. Developers run --no-verify. Every hook should take under a second or move to pre-push.
Committing to .git/hooks/. Those files aren't in the repo. Use a hook manager.
Nested submodules. A submodule that has submodules. Every command grows another --recursive. Avoid if possible.
Huge files without LFS. Once a big file is committed, it's in history. Cloning downloads all history. Consider git-filter-repo to excise it, then use LFS going forward.
Worktrees with uncommitted changes. Don't remove a worktree that has uncommitted work; the changes are gone. git status in the worktree first.
Next Steps
Continue to 12-best-practices.md for the conventions that keep a repo healthy.