Git Hooks, Submodules, and Git LFS
Three ways Git extends past "track my source files": hooks that run your own scripts at key points, submodules for embedding one repo inside another, and LFS for handling large binaries without bloating the object store from the previous chapter.

A teammate once pushed a commit with a hardcoded API key straight to main because nothing stopped them.
The linter would have caught it — but only ran in CI, minutes after the damage was already public.
We added a hook that ran the same check locally, before the commit could even be made. That exact mistake never happened again.
Hooks, submodules, and LFS all solve a version of the same underlying problem: Git's core model (snapshots of text files) is minimal on purpose, and these are the sanctioned ways to extend it without changing that model.
Git hooks — scripts that run at specific points
Every Git repository has a .git/hooks/ directory pre-populated with example scripts — all disabled by default, all named like pre-commit.sample. Removing .sample and making the file executable activates it.
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
The most common hooks
pre-commit — runs right before a commit is finalized. A non-zero exit code blocks the commit entirely.
This is where you put linting, formatting checks, or secret scanning:
#!/bin/sh
# Block commits containing what looks like an AWS access key
if git diff --cached | grep -qE "AKIA[0-9A-Z]{16}"; then
echo "❌ Commit blocked: possible AWS key detected in staged changes."
exit 1
fi
Local hooks are a guardrail, not security enforcement: a developer can bypass pre-commit with git commit --no-verify. Keep the same secret and quality checks in CI or server-side controls.
pre-push — runs before git push sends anything to the remote. Good place for slower checks you don't want on every single commit — running the full test suite, for instance — but do want before anything reaches a shared branch.
commit-msg — runs after you write a commit message but before it's saved. Can enforce message format:
#!/bin/sh
# Enforce Conventional Commits format
if ! grep -qE "^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .+" "$1"; then
echo "❌ Commit message must follow: type(scope): description"
exit 1
fi
The catch — hooks aren't tracked by Git
Hooks in .git/hooks/ live outside the repository's actual version-controlled content. They don't get shared just by cloning or pulling.
Teams that want shared hooks have two options:
-
Commit hook scripts to a tracked folder (e.g.,
hooks/) with a setup step that symlinks them into.git/hooks/:bashln -sf ../../hooks/pre-commit .git/hooks/pre-commit -
Use Husky (standard in Node.js projects) — it automates hook wiring as part of
npm install:bashnpx husky init
- If your team uses Husky, run
npm installafter cloning and hooks work immediately. If not, check if there's ahooks/folder in the repo and follow the setup instructions in README.
Submodules — embedding one repository inside another
A submodule is a reference, inside your repository, to a specific commit of a different repository.
Used when your project genuinely depends on another project's source — not just a published package — and you want that dependency's exact history alongside your own.
git submodule add git@github.com:org/shared-ui-library.git libs/shared-ui
This creates:
- A
.gitmodulesfile (tracked and shared) recording the submodule's URL and path - A special entry in your repository's tree pointing at one specific commit of the submodule — not a branch, a fixed hash
Cloning a repo with submodules
Cloning doesn't automatically pull submodule content:
git clone --recurse-submodules git@github.com:org/main-project.git
# or, after a normal clone:
git submodule update --init --recursive
Updating a submodule
Updating to a newer version is an explicit two-step process:
cd libs/shared-ui
git pull origin main # go into the submodule and update it
cd ../..
git add libs/shared-ui # stage the new pointer hash
git commit -m "Bump shared-ui submodule to latest main"
That last commit in your main repo doesn't contain any of the submodule's actual file changes — just the updated pointer (a new commit hash) to which version your project now depends on.
-
This pinning is the point. Your build is reproducible against an exact, known commit of the dependency, not whatever happens to be on its
mainbranch today. -
Submodules have a learning curve. Forgetting to run
git submodule update --initafter a pull, or trying to push changes from inside a submodule without committing the pointer change in the parent repo, are common gotchas. -
When to actually use submodules: The main signal is "the dependency doesn't have a separate release cadence or published package — it's source code you need to embed at a specific version." If you can just
npm installor use any other package manager instead, that's almost always the better choice. Submodules make sense for things like shared tooling repos or vendored dependencies where you want git history and exact version pinning together.
Git LFS — large file storage
Git's object model stores every version of every file forever and diffs text files efficiently by comparing lines. Neither works well for binary assets:
- A 200MB video file: every version stored in full
- A binary Photoshop file: no meaningful line-based diff possible
- Result: repository keeps growing, every clone downloads all of it
Git LFS (Large File Storage) changes what actually gets stored for specified file patterns.
Setting up LFS
git lfs install # one-time setup per machine
git lfs track "*.psd" # track Photoshop files with LFS
git lfs track "*.mp4" # track videos
git add .gitattributes # commit the tracking rules
git lfs track writes rules into .gitattributes (a tracked file, shared with everyone).
How it works
Without LFS, every version of that 200MB file gets stored in full inside .git/objects/. With LFS, Git stores a tiny pointer file (just a few bytes of text) in the repository, and the actual binary content lives on a separate LFS server. When you clone or pull, LFS transparently downloads the real file.
git add design.psd
git commit -m "Add homepage mockup"
git push origin main
To anyone with LFS installed, this behaves like a completely normal commit and push — LFS intercepts the process transparently.
- The adoption cost: Everyone working with the repository needs to have
git lfs installrun once. Without it, cloning gets the small pointer files instead of the actual binary content — which looks broken and confusing.
Which one do you need?
| Problem | Solution |
|---|---|
| Need automated checks to run before commit/push | Git hooks |
| Need to enforce hooks across the whole team | Hooks + Husky or symlink setup |
| Your project depends on another repo's exact source at a pinned version | Submodules |
| Your repo has large binary files that are bloating clone size | Git LFS |
All three are opt-in. None of this happens unless a repository is deliberately set up for it.
The last piece is how all of this fits into an actual team's day-to-day process: named branching strategies, commit message conventions, CI pipelines, and a troubleshooting reference for the messy situations that don't map cleanly to a single topic.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.