N
Naveenr.dev
Chapter 08
9 min read2026-08-12
📖 Git SeriesChapter 08 · 11 chapters

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.

Git hooks, submodules, and Git LFS
Git hooks, submodules, and Git LFS

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.

bash
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:

bash
#!/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:

bash
#!/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:

  1. Commit hook scripts to a tracked folder (e.g., hooks/) with a setup step that symlinks them into .git/hooks/:

    bash
    ln -sf ../../hooks/pre-commit .git/hooks/pre-commit
    
  2. Use Husky (standard in Node.js projects) — it automates hook wiring as part of npm install:

    bash
    npx husky init
    
  • If your team uses Husky, run 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.

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.

bash
git submodule add git@github.com:org/shared-ui-library.git libs/shared-ui

This creates:

  • A .gitmodules file (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:

bash
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:

bash
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 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

bash
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.

bash
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 install run 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?

ProblemSolution
Need automated checks to run before commit/pushGit hooks
Need to enforce hooks across the whole teamHooks + Husky or symlink setup
Your project depends on another repo's exact source at a pinned versionSubmodules
Your repo has large binary files that are bloating clone sizeGit 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.