Git ยท 8 of 11
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.
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
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
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/:
ln -sf ../../hooks/pre-commit .git/hooks/pre-commit
Use Husky (standard in Node.js projects) โ it automates hook wiring as part of npm install:
npx husky init
npm install after cloning and hooks work immediately. If not, check if there's a hooks/ folder in the repo and follow the setup instructions in README.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:
.gitmodules file (tracked and shared) recording the submodule's URL and pathCloning 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 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 main branch today.
Submodules have a learning curve. Forgetting to run git submodule update --init after 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 install or 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's object model stores every version of every file forever and diffs text files efficiently by comparing lines. Neither works well for binary assets:
Git LFS (Large File Storage) changes what actually gets stored for specified file patterns.
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).
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.
git lfs install run once. Without it, cloning gets the small pointer files instead of the actual binary content โ which looks broken and confusing.| 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.
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.