Module 14 — Ignoring, Attributes, and Big Files

Updated 7 September 2026

Module 14 — Ignoring, Attributes, and Big Files. Modules so far taught what Git does with your files; this one controls what enters the repository and how. .gitignore keeps clutter and secrets out; .gitattributes fixes line-ending chaos and controls diffs; Git LFS solves Module 3's binary-bloat warning; submodules embed one repo inside another. These are the tools that keep a real repository clean, portable across operating systems, and a sane size.

🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start. You need Module 2 (tracked vs untracked, git rm --cached), Module 3 (the binary-bloat warning this module resolves), and Module 8 (remotes — LFS and submodules involve them). Part C needs git-lfs installed (git lfs version); Part D uses local repos as submodules. The exercises build fresh repos as needed.

Part A — .gitignore: keeping files out

A1. Patterns, and what they match

.gitignore is a file listing patterns for paths Git should not track — build output, logs, dependency directories, local secrets, editor droppings. Once a path matches, Git stops offering it in git status and won't stage it with git add .. The pattern rules: a bare name or glob (*.log, secret.key) matches anywhere; a trailing slash (build/) matches directories; a leading slash (/config.local) anchors to the repo root; and a leading ! re-includes something a previous pattern excluded (*.log then !keep.log). .gitignore files are themselves committed and can sit in any directory (applying to that subtree), so the ignore rules travel with the repo — everyone on the team ignores the same clutter automatically.

🧪 Exercise 14.1 — ignore clutter, watch status go quiet
bash
mkdir -p ~/git-course && cd ~/git-course
git init -q kitchen && cd kitchen
echo "app code" > app.py && git add app.py && git commit -q -m "Add app"
mkdir -p logs build
echo "run log" > logs/app.log
echo "artifact" > build/out.o
echo "secret=123" > .env
git status -s                         # clutter everywhere
cat > .gitignore <<'EOF'
# ignore logs and build output
*.log
build/
.env
EOF
git status -s                         # ...silenced
Expected result — click to reveal
plain text
?? .env
?? build/
?? logs/
?? .gitignore

What to read out of it: the first git status -s (top of the output) listed .env, build/, and logs/ as untracked clutter. After writing .gitignore, the second status shows only ?? .gitignore — the log, build directory, and secret vanished from Git's view: they still exist on disk, but Git now pretends not to see them, so a careless git add . can't sweep them in. The .gitignore file itself is untracked (you'll commit it — ignore rules belong in history so the whole team shares them). This is the single most important habit for keeping secrets and junk out of a repository: ignore before the first git add ., not after.

A2. The already-tracked trap

Official docs: gitignore manual

Here is the trap that catches everyone once: .gitignore only affects untracked files. If a file is already tracked (you committed it before ignoring it), adding it to .gitignore does nothing — Git keeps tracking it, keeps showing its changes, keeps committing them. The fix is Module 2's git rm --cached <file>: stop tracking it (remove from the index) while keeping it on disk, then commit that removal. From that commit forward, the .gitignore rule takes over. And git check-ignore -v <path> is the debugger for "why is (or isn't) this ignored?" — it names the exact .gitignore file and line that decides.

🧪 Exercise 14.2 — the trap, and the escape
bash
cd ~/git-course/kitchen
git add .gitignore && git commit -q -m "Add gitignore"
echo "config data" > config.cache
git add config.cache && git commit -q -m "Add config.cache"   # oops, committed it
echo "*.cache" >> .gitignore          # now try to ignore it...
git status -s                         # ...gitignore did NOT untrack it
git rm --cached config.cache          # the real fix
git status -s
git commit -q -am "Stop tracking config.cache"
git check-ignore -v config.cache      # now Git confirms it's ignored
ls config.cache                       # still on disk
Expected result — click to reveal
plain text
 M .gitignore
rm 'config.cache'
 M .gitignore
D  config.cache
.gitignore:5:*.cache	config.cache
config.cache

What to read out of it: after adding *.cache to .gitignore, git status -s shows only M .gitignoreconfig.cache is not listed as untracked or ignored, because it's still tracked; the ignore rule was silently powerless. git rm --cached then stages its removal (D config.cache) while leaving the file on disk (ls still finds it). After that commit, git check-ignore -v config.cache finally reports a match (.gitignore:5:*.cache) — proof the ignore now applies. The lesson interviewers probe: ignoring a file you already committed is a two-step operation, and forgetting rm --cached is why "I added it to gitignore but Git still tracks it" is one of the most common Git confusions. (Note: while a file is still tracked, check-ignore reports nothing for it — the tool considers tracked paths not-ignored; the match only appears once it's untracked.)

Trap: the deepest cut of the already-tracked trap is secrets. Adding .env to .gitignore after it was committed does not remove it from history — the secret sits in every past commit, fetchable by anyone with the repo (Module 3: commits are immutable, deleting the current file changes nothing about old snapshots). git rm --cached stops future tracking but the leaked value is already in the object database forever. Real remediation is two things: rotate the secret (assume it's compromised — it left your machine), and if it must be scrubbed from history, rewrite the entire history with git filter-repo (Module 15) and force-push — an all-hands, disruptive operation. The only cheap fix is prevention: ignore secrets before the first commit, and never commit them in the first place.
Real-world analogy — the "do not file" note on a desk

.gitignore is a sticky note on your inbox: "don't file anything matching these — junk mail, drafts, sticky notes themselves." New matching papers get skipped automatically. It keeps your filing cabinet (the repo) full of only real documents.

Where the analogy stops working. The note governs incoming papers only — it has zero power over documents already filed. Slap "don't file bank statements" on your inbox and the twelve statements already in the cabinet stay filed until you physically pull them (git rm --cached). And pulling today's copy doesn't erase the photocopies in last year's archive boxes (old commits) — which, for a leaked secret, is the whole problem. The note prevents; it does not un-file, and it certainly does not shred the archive.

🎯 Interview questions — Part A

🎯 "What is the purpose of the .gitignore file?" — asked verbatim in Interview Coder (Sep 2025) and GeeksforGeeks (updated Jul 2026)

.gitignore lists path patterns Git should not track — build artifacts, logs, dependency folders (node_modules/), local config, and secrets — so git status stays clean and git add . can't accidentally stage clutter. Patterns support globs (*.log), directory (build/) and root-anchored (/x) forms, and negation (!keep.this). The files are committed, so ignore rules are shared by the whole team; they can live per-directory. Crucially, .gitignore affects only untracked files — an already-committed file must be untracked with git rm --cached before the rule applies.

The details that separate candidates: an average answer says "lists files Git ignores." A strong answer states the untracked-only rule and the git rm --cached fix, warns that ignoring a committed secret does not remove it from history (rotate + filter-repo), and knows the debugging tool git check-ignore -v. Mentioning global ignores (core.excludesFile for editor/OS junk that shouldn't be in every project's .gitignore) and ! negation rounds out real fluency.

🎯 "How do you remove tracking but keep the file locally?" — asked verbatim in Interview Coder, Sep 2025; also as "How does Git handle file deletion?" in GeeksforGeeks (updated Jul 2026)

git rm --cached <file> — it removes the file from the index (Git stops tracking it) while leaving it untouched on disk, unlike plain git rm which deletes it from both. Commit the removal, and from that point Git no longer versions the file; pair it with a .gitignore entry so it doesn't get re-added. The canonical uses: a file that was committed before it should have been ignored (a config, a cache, an accidentally-committed secret), and stopping tracking of a now-generated file.

The details that separate candidates: an average answer gives the command. A strong answer contrasts it with git rm (both vs disk-only), pairs it with the .gitignore follow-up, -r for directories, and states the hard truth for secrets: --cached stops future tracking but the value remains in every historical commit — rotate it and, if required, rewrite history. That last point is the difference between "stopped tracking" and "actually removed the leak."

Part B — .gitattributes: line endings and diffs

B1. Ending the CRLF wars

Windows ends text lines with CRLF (\r\n); Linux and macOS use LF (\n). On a mixed team this causes phantom diffs — a file looks "entirely changed" because someone's editor rewrote every line ending, drowning the real one-line change. .gitattributes fixes this by declaring, per file pattern, how Git should handle content. * text=auto tells Git to normalize text files to LF in the repository and convert to the platform's convention on checkout; *.csv text eol=lf forces LF for specific types; *.png binary marks files Git should never touch or try to diff. Like .gitignore, it's committed, so the whole team gets consistent handling. git check-attr -a <file> shows which attributes apply to a path.

🧪 Exercise 14.3 — normalize a CRLF file to LF in the repo
bash
cd ~/git-course/kitchen
printf 'name,qty\r\nflour,500\r\n' > data.csv   # a CRLF (Windows) file
cat > .gitattributes <<'EOF'
* text=auto
*.csv text eol=lf
*.png binary
EOF
git add .gitattributes data.csv               # note the warning Git prints
git commit -q -m "Add attributes and CRLF data"
git check-attr -a data.csv                    # which attributes apply?
git show HEAD:data.csv | cat -A | head -2     # what got STORED — LF or CRLF?
Expected result — click to reveal
plain text
warning: in the working copy of 'data.csv', CRLF will be replaced by LF the next time Git touches it
data.csv: text: set
data.csv: eol: lf
git show output:
name,qty$
flour,500$

What to read out of it: git add printed a warning that it will normalize CRLF to LF — Git is telling you it's about to standardize the line endings per your attributes. git check-attr -a confirms the rules applying to data.csv: text: set and eol: lf. The payoff is the last command: git show HEAD:data.csv | cat -A reveals what's stored in the repository — each line ends in $ (LF) with no ^M (which is how cat -A shows CR). So the file went in with Windows CRLF, but the committed blob is clean LF — every platform now checks out consistent content, and the "whole file changed" phantom diffs stop. The repository holds one canonical form; each OS gets its native form on checkout.

Counter-intuitive: .gitattributes (committed, shared) and the core.autocrlf config (per-machine) both handle line endings — and using config alone is the classic mistake. core.autocrlf lives in each developer's local Git settings, so it's not shared: one teammate has it true, another false, a third unset, and you're back to inconsistent line endings despite everyone "having it configured." .gitattributes is committed into the repo, so it applies identically to everyone who clones — it's the authoritative, portable fix, and core.autocrlf is at best a per-machine fallback. The rule: put line-ending policy in .gitattributes (in the repo, shared by construction), not in each person's config where it inevitably drifts. Teams that rely on autocrlf re-fight the CRLF war with every new hire.

🎯 Interview questions — Part B

🎯 Corpus note — .gitattributes and line endings

Neither .gitattributes nor core.autocrlf appears as a verbatim standalone question in the surveyed corpus (Interview Coder, GeeksforGeeks) — both surface only inside broader answers (often the LFS question, since LFS uses .gitattributes) — so this Part carries a corpus note rather than a fabricated block. The interview-ready substance: .gitattributes declares per-pattern handling (line-ending normalization via text/eol, binary to skip diffing, plus diff/merge drivers and the LFS filter), it's committed so it's shared, and it's the authoritative line-ending fix versus the per-machine core.autocrlf config that drifts across a team. The signal is knowing why committed attributes beat per-developer config — the same "shared, portable, in-the-repo" theme as .gitignore.

Part C — Git LFS: large files done right

C1. Pointers instead of blobs

Official docs: Git LFS · gitattributes manual

Module 3 warned it: Git stores a whole new blob for every version of every file, and binaries don't delta-compress — so committing a 50 MB design file ten times bloats the repo by ~500 MB forever (immutable history). Git LFS (Large File Storage) solves this. For file patterns you designate, LFS commits a tiny pointer file (a few lines: a hash and size) into Git history, while the actual bytes are stored separately — in .git/lfs locally and on an LFS server remotely. History stays small and fast; the big files are fetched on demand. Setup is git lfs install (once), then git lfs track "<pattern>" — which writes an LFS filter into .gitattributes (Part B's file, now doing storage work). From then on, matching files are transparently swapped for pointers on commit and restored on checkout.

🧪 Exercise 14.4 — commit a binary through LFS and see the pointer
bash
cd ~/git-course && git init -q lfsdemo && cd lfsdemo
git lfs install --local
git lfs track "*.psd"                 # designate the pattern
cat .gitattributes                    # LFS wrote a filter line
head -c 5000 /dev/urandom > design.psd   # a stand-in "large binary"
git add .gitattributes design.psd
git commit -q -m "Add design via LFS"
git show HEAD:design.psd              # what's in HISTORY — bytes or pointer?
git lfs ls-files                      # which files LFS manages
find .git/lfs -type f                 # where the real bytes live
Expected result — click to reveal
plain text
*.psd filter=lfs diff=lfs merge=lfs -text
version https://git-lfs.github.com/spec/v1
oid sha256:371eb4911d29f2d6500dab859fbedf5264d4ce8216c741f34efc9c48cf17d3b7
size 5000
371eb4911d * design.psd
.git/lfs/objects/37/1e/371eb4911d29...

What to read out of it: git lfs track wrote *.psd filter=lfs diff=lfs merge=lfs -text into .gitattributes — that filter is what intercepts these files. The revealing command is git show HEAD:design.psd: what's stored in Git history is not the 5000 random bytes but a three-line pointer — a spec URL, the content hash (oid sha256:…), and the size. The real bytes sit in .git/lfs/objects/… (and would be pushed to an LFS server, not the Git remote). git lfs ls-files confirms LFS manages design.psd. So the repo's history carries a few dozen bytes per version instead of the full file — Module 3's bloat problem, dissolved: version a huge asset 100 times and history grows by 100 tiny pointers, not 100 full copies.

Now imagine this at 500 hosts. LFS is what makes game studios, ML teams, and design orgs able to use Git at all. Without it, a repo of textures, model weights, or datasets balloons to hundreds of gigabytes, and every clone drags the entire history of every binary version — clones take hours, disks fill. With LFS, git clone fetches pointers plus only the LFS objects for the checked-out commit (or none, with lazy fetch), so onboarding stays minutes not hours. The operational costs to plan for: an LFS server/quota (GitHub, GitLab, or self-hosted), and the discipline to lfs track binaries before committing them — because a big file committed without LFS is already bloating history and needs the same painful history-rewrite (Module 15) to extract as any other mistake. The rule mirrors .gitignore's: get the policy right before the first commit.

🎯 Interview questions — Part C

🎯 "How do you handle large files with Git?" — asked verbatim in Interview Coder (Sep 2025) and GeeksforGeeks (updated Jul 2026)

Git LFS (Large File Storage). You git lfs track "<pattern>" (which registers a filter in .gitattributes), and thereafter Git commits a small text pointer — a content hash and size — into history while the actual bytes are stored out-of-band (in .git/lfs locally, on an LFS server remotely) and fetched on demand. This keeps history small and clones fast even for repos full of large binaries, solving Git's core weakness: it stores a full new blob per version and binaries don't delta-compress, so versioned large files bloat history permanently. Key discipline: track before the first commit of the file.

The details that separate candidates: an average answer says "use Git LFS." A strong answer explains the pointer mechanism and why it's needed (Git's per-version-full-blob storage + no binary delta compression), notes that tracking must precede the commit (a file committed without LFS is already bloating history and needs a history rewrite to remove), and mentions the operational tail: LFS needs server support/quota, and git lfs ls-files/lazy fetch for large-repo workflows. Naming the alternative for the follow-up "without LFS?" — external artifact stores/package registries with the repo holding only references — shows range.

🎯 "How do you manage large binary files in Git without using Git LFS?" — asked verbatim in GeeksforGeeks' 70+ Git Interview Questions, updated Jul 2026

Keep the binaries out of Git and store references instead. Practical approaches: an artifact repository or package registry (Artifactory, Nexus, npm/PyPI, a container registry) holding the binaries, with Git committing only a version/URL/checksum the build resolves; cloud object storage (S3/GCS) addressed by a manifest in the repo; or a package/dependency manager that pulls the assets at build time. The repo stays a lightweight source-of-truth of code plus pointers, and the heavy bytes live where large-object storage is cheap and versioned. Alternatives inside the Git family include git-annex (LFS's conceptual predecessor) for pointer-style management without the LFS service.

The details that separate candidates: an average answer just names one store. A strong answer frames the principle — Git is for text/source; large or opaque binaries belong in systems built for them, with Git holding references — and matches the tool to the case (registry for build outputs, object storage + manifest for datasets, package manager for third-party assets). Noting git-annex and the checksum-in-repo pattern (so the reference is verifiable) demonstrates depth beyond "just use S3."

Part D — Submodules: a repo inside a repo

D1. Embedding a pinned dependency

Sometimes you need another Git repository inside yours — a shared library, a vendored dependency — pinned to a specific commit rather than merged in. A submodule does exactly that: git submodule add <url> <path> clones the other repo into a subdirectory and records, in your repo, a pointer to one exact commit of it (plus a .gitmodules file mapping path → URL). Your repo doesn't store the submodule's files or history — it stores a gitlink: a special entry naming the commit the submodule should be at. That pinning is the point: the parent repo controls which version of the dependency it uses, and updating is a deliberate act (move the pointer, commit), not an automatic drift. The tradeoff is friction, which the exercise and trap make concrete.

🧪 Exercise 14.5 — add a submodule, inspect the gitlink

This uses local repos so it runs offline; real submodules use remote URLs. The -c protocol.file.allow=always flag is only needed for local-path submodules (a security default).

bash
cd ~/git-course
git init --bare -q shared-lib.git             # a stand-in "shared library" remote
git clone -q shared-lib.git shared-seed && cd shared-seed
echo "shared utility v1" > util.sh && git add util.sh && git commit -q -m "Add shared util"
git push -q origin main
cd ~/git-course/kitchen
git -c protocol.file.allow=always submodule add ../shared-lib.git libs/shared
cat .gitmodules                               # the path -> URL mapping
git ls-files -s libs/shared                   # the GITLINK (mode 160000)
git commit -q -m "Add shared library as submodule"
Expected result — click to reveal
plain text
[submodule "libs/shared"]
	path = libs/shared
	url = ../shared-lib.git
160000 41025d8372e5d74fa30bb706864686891f218814 0	libs/shared

What to read out of it: .gitmodules records the mapping — path libs/shared ← URL ../shared-lib.git — and this file is committed, so anyone cloning knows where to get the submodule. The revealing line is git ls-files -s: the entry for libs/shared has mode 160000 — the special gitlink mode (not 100644 for a file or 040000 for a tree; Module 3's tree entries, now with a fourth kind). It names a single commit (41025d8…) of the other repository. Your repo stores that 40-character pointer, not the library's files — so the parent is pinned to exactly commit 41025d8 of the shared lib until someone deliberately moves the pointer. That pin is the submodule's whole reason to exist.

D2. The clone-and-update friction

Submodules' pinning comes with a famous cost: cloning a repo does not fetch its submodules' contents by default. A fresh clone gets the gitlink and .gitmodules but leaves submodule directories empty until you run git submodule update --init (or clone with --recurse-submodules). Forgetting this is the #1 submodule confusion — "the library folder is empty and the build fails." And this friction repeats: pulling parent changes doesn't auto-update submodules, updating a submodule is a two-repo commit dance, and every teammate must remember the --recurse flags. The friction is real enough that many teams prefer package managers or subtrees for shared code, reserving submodules for cases where exact-commit pinning of a separate repo is genuinely required.

🧪 Exercise 14.6 — clone, find the empty dir, then populate it
bash
cd ~/git-course
git -c protocol.file.allow=always clone -q kitchen kitchen-clone
cd kitchen-clone
ls libs/shared                        # empty! the trap
git submodule status                  # note the leading '-' (not initialized)
git -c protocol.file.allow=always submodule update --init
cat libs/shared/util.sh               # now the content is there
git submodule status                  # '-' is gone
Expected result — click to reveal (the first ls shows an empty dir on purpose)
plain text
-41025d8372e5d74fa30bb706864686891f218814 libs/shared
Submodule path 'libs/shared': checked out '41025d8372e5d74fa30bb706864686891f218814'
shared utility v1
 41025d8372e5d74fa30bb706864686891f218814 libs/shared (heads/main)

What to read out of it: right after cloning, ls libs/shared prints nothing — the directory exists but is empty, the classic "why is my dependency missing?" moment. git submodule status shows the commit with a leading -, meaning "not initialized." After git submodule update --init, Git clones the submodule at exactly the pinned commit (checked out '41025d8…') and the file appears (shared utility v1); status now shows the same hash with no - (and (heads/main)), meaning populated and healthy. The takeaway: a repo with submodules needs --recurse-submodules on clone (or submodule update --init after) — bake it into your setup docs, because the empty-directory trap catches every newcomer exactly once.

🎯 Interview questions — Part D

🎯 "What is the use of git submodule?" — asked verbatim in Interview Coder (Sep 2025); also "What is a Git submodule?" in GeeksforGeeks (updated Jul 2026)

A submodule embeds another Git repository inside yours at a subdirectory, pinned to one specific commit. Your repo stores a gitlink (mode 160000 — a pointer to that exact commit) plus a committed .gitmodules file mapping the path to the submodule's URL; it does not store the submodule's files or history. Use it when you need a fixed, deliberately-updated snapshot of a separate repo — a shared library or vendored dependency — with the parent controlling exactly which version it uses. Add with git submodule add <url> <path>; clone consumers need --recurse-submodules (or git submodule update --init) to actually populate it.

The details that separate candidates: an average answer says "a repo inside a repo." A strong answer states the pinning-to-a-commit purpose (that's why you'd choose it over a plain copy), the gitlink mechanism, and the real friction — clones don't fetch submodule content by default, updates are a two-repo dance — which is why teams often prefer package managers or subtrees unless exact-commit pinning of a separate repo is essential. Knowing when not to use submodules is as valuable as knowing how.

Part E — Production practice

E1. Symptom → cause → diagnosis → fix

SymptomWhat is really happeningWhat to runThe fix
"I added it to .gitignore but Git still tracks it".gitignore affects untracked files only; this one's already trackedgit check-ignore -v <file> (reports nothing while tracked)git rm --cached <file> then commit; the rule applies from then on
A secret got committed; adding it to .gitignore "removed" itIt's gone from the working tree's tracking but remains in all historygit log -p -- <file> (still shows the value in old commits)Rotate the secret NOW; scrub history with git filter-repo (Module 15) + force-push if required
A file shows as "entirely changed" though you edited one lineLine endings were rewritten (CRLF↔LF) by an editor or a teammate's configgit diff (whole file red/green) · git check-attr -a <file>Commit a .gitattributes with • text=auto; renormalize (git add --renormalize .)
Line endings inconsistent despite "everyone set autocrlf"core.autocrlf is per-machine config — it drifts across the teamAsk three teammates for git config core.autocrlf (three answers)Move policy into committed .gitattributes — shared by construction
Repo is huge and clones take foreverLarge binaries were committed directly (no LFS) — full blob per version, no deltaFind big objects (Module 15's rev-list tricks) · git lfs ls-filesAdopt LFS going forward; migrate existing blobs with git lfs migrate • history rewrite
Cloned a repo, a dependency folder is empty, build failsSubmodule content isn't fetched on a plain clonegit submodule status (leading - = not initialized)git submodule update --init (or re-clone with --recurse-submodules)
Teammate's "submodule update" shows a modified submodule you didn't changeSomeone committed the submodule at a new pinned commit; yours is at the old onegit diff (shows the gitlink hash changing) · git submodule statusgit submodule update to move to the pinned commit; commit if you intend the bump

E2. Capstone — four tickets

Ticket 1 — "Set up ignore hygiene for a new polyglot repo." A service with Python, a Node front-end, Terraform, and local .env secrets. Design the ignore strategy so nothing sensitive or generated ever gets committed.

Worked answer: committed .gitignore (or several, per-subtree) as the shared baseline — start from a language-appropriate template (gitignore.io / GitHub's templates) covering __pycache__/, node_modules/, .terraform/, build output, and OS/editor junk, plus explicit secret patterns (.env, *.pem, *.tfvars). Layer a global ignore (git config --global core.excludesFile ~/.gitignore_global) for personal editor/OS files (.DS_Store, .idea/) so they don't clutter every project's committed ignore. Enforce that secrets can't slip in before the first commit: a pre-commit hook (Module 15) or CI scan (gitleaks, git-secrets) that rejects commits containing key patterns — because .gitignore only helps if the file matches a rule and nobody git add -f's past it. State the non-negotiable: ignore secrets before the first commit, because after is a rotate-and-rewrite incident (Ticket in Part A's trap). One baseline .gitignore, one global ignore, one automated guard.

Ticket 2 — "Our Windows/Mac team has constant phantom diffs." Every PR shows whole files changed with no real edits; reviewers can't see the actual change. Diagnose and fix permanently.

Worked answer: classic CRLF↔LF churn — editors on different OSes rewrite line endings, and (the root cause) the team relied on per-machine core.autocrlf, which drifts. Permanent fix: commit a .gitattributes with * text=auto (normalize all text to LF in the repo, native on checkout) plus explicit rules for known types (*.sh text eol=lf, *.bat text eol=crlf, *.png binary). Then renormalize the existing repo once: git add --renormalize . and commit — this rewrites the stored blobs to canonical LF in a single, reviewable "normalize line endings" commit, after which diffs are clean. Communicate the one-time noisy commit so it's not mistaken for a real change, and ideally merge it when no long-lived branches are open (to minimize conflicts). The lasting lesson for the team: line-ending policy belongs in committed .gitattributes, never in each developer's config.

Ticket 3 — "The repo hit 40GB and onboarding takes half a day." Investigation shows gigabytes of committed model checkpoints and design assets, versioned directly in Git over two years. Fix it and prevent recurrence.

Worked answer: two phases — stop the bleeding, then (carefully) clean the wound. Prevent going forward: git lfs install, git lfs track "*.ckpt" "*.psd" "*.bin" (writes .gitattributes), so all future large-file commits become pointers — history stops growing. For the existing bloat, the honest options: git lfs migrate import --include="*.ckpt,*.psd" rewrites history to move past large files into LFS — but this is a history rewrite (new hashes for every affected commit, Module 10's golden rule at repo scale): it requires coordinating a force-push, everyone re-cloning, and accepting that old commit hashes change; schedule it as an announced migration, not a quiet fix. Alternatively, if the binaries don't need versioning at all, move them out of Git entirely into object storage / an artifact registry (Part C's no-LFS answer) with the repo holding only references. Set up quota/monitoring on the LFS store and a CI check rejecting non-LFS large files (git lfs pre-push hook). The framing: this is expensive precisely because the policy wasn't set before the first big commit — the whole ticket is a lesson in getting LFS/ignore rules right on day one.

Ticket 4 — "Should we use a submodule for our shared auth library?" Three services need a common auth library. A submodule is proposed; write the recommendation.

Worked answer: decide by how the dependency is consumed, and lean against submodules unless exact-commit pinning of a live repo is essential. Submodule fits if all three services must pin an exact commit of the auth repo, coordinate lock-step, and you accept the friction (every clone needs --recurse-submodules, updates are a two-repo dance, newcomers hit the empty-directory trap). Package manager (publish the auth library as a versioned internal package — npm/PyPI/Maven/Go module) is usually better: consumers pin a semver range (Module 12), updates are a one-line version bump, tooling handles fetching, and there's no submodule friction — this is the default recommendation for shared code. Subtree is the middle path if you want the library's files vendored directly into each repo without the submodule ceremony, at the cost of a more complex update flow. Recommend the package-manager route unless a concrete requirement (e.g. the services build the auth code from source in lock-step, or it's not publishable) forces submodules. The interview close: submodules solve exact-commit pinning of a separate repo, but most "shared library" needs are better served by versioned packages — knowing when not to reach for submodules is the senior signal.

E3. Documentation reference

TopicOfficial sourceWhat it covers
.gitignoregitignore manualPattern syntax, precedence, negation, per-dir files
Ignoring files (tutorial)Ignoring files — GitHub DocsGlobal ignores, templates, common cases
.gitattributesgitattributes manual · Git Book §8.2text/eol/binary, diff & merge drivers, filters
Git LFSgit-lfs.comInstall, track, pointers, migrate, ls-files
SubmodulesGit Book §7.11 — Submodules · git-submodule manualadd/update/status, gitlink, --recurse-submodules

E4. Self-assessment

Answer each aloud, from memory, before moving on. Every one is answered on this page.

  1. What does .gitignore affect, and — crucially — what does it not affect?
  2. You committed a file, then added it to .gitignore. Why is it still tracked, and what's the exact fix?
  3. Why does adding a leaked secret to .gitignore not actually protect it, and what are the two real remediation steps?
  4. What does git check-ignore -v tell you, and what does it report for an already-tracked file?
  5. What problem does .gitattributes solve for mixed-OS teams, and which one line addresses most of it?
  6. Why is .gitattributes the authoritative line-ending fix and core.autocrlf only a fallback?
  7. What does Git commit into history for an LFS-tracked file, and where do the real bytes live?
  8. Why must you git lfs track a pattern before committing matching files?
  9. Give one way to manage large binaries without LFS, and the principle behind it.
  10. What is a gitlink (mode 160000), and what does a repo store for a submodule?
  11. Why is a freshly cloned submodule directory empty, and the two commands that fix it?
  12. When would you choose a package manager over a submodule for shared code, and why?

E5. Sources

Interview questions in this module were captured verbatim from: Interview Coder — 90+ Common Git Interview Questions (Sep 20, 2025) and GeeksforGeeks — Top 70+ Git Interview Questions (updated Jul 30, 2026). A corpus note: .gitignore, git rm --cached, LFS, and submodules are well-represented as verbatim questions, but .gitattributes/line-endings appear only inside broader answers — so Part B carries a corpus note rather than a fabricated block (the track's rule: never invent a question and present it as published). Technical claims were verified against the official documentation in E3; every command and output on this page was executed on Git 2.43.0 with git-lfs 3.4.1 on Linux, including the real LFS pointer and the local-path submodule flows. Content hashes, LFS OIDs, gitlink hashes, and paths will differ on your machine.

🗒️ Cheat sheet — Module 14

Command / fileWhat it does
.gitignore patterns: *.log · build/ · /root · !keepGlob anywhere · directory · root-anchored · negate (re-include)
git check-ignore -v <path>Show which .gitignore line decides a path (nothing if it's tracked)
git rm --cached <file> · -r <dir>Stop tracking, keep on disk — the fix for an already-committed file
git config --global core.excludesFile ~/.gitignore_globalPersonal/OS ignores across all repos (not committed)
.gitattributes: • text=auto · *.x text eol=lf · *.png binaryNormalize line endings in-repo · force LF · never diff/touch
git check-attr -a <file> · git add --renormalize .Show attributes applying to a path · re-normalize stored blobs once
git lfs install · git lfs track "<pat>" · git lfs ls-filesEnable LFS · designate a pattern (writes .gitattributes) · list LFS files
git lfs migrate import --include="<pat>"Move existing committed blobs into LFS (rewrites history!)
git submodule add <url> <path> · cat .gitmodulesEmbed a repo pinned to a commit (gitlink 160000) · the path→URL map
git clone --recurse-submodules · git submodule update --initClone and populate submodules · populate them after a plain clone

Key concepts: .gitignore keeps untracked files out of Git — it does NOT untrack already-committed files (use git rm --cached), and it does NOT remove a leaked secret from history (rotate + rewrite) · patterns: globs, dir/, /anchored, !negation; check-ignore -v debugs them; global ignores for personal junk · .gitattributes (committed, shared) is the authoritative line-ending fix — * text=auto normalizes to LF in-repo; beats per-machine core.autocrlf · Git LFS commits a small pointer (hash+size) to history and stores real bytes out-of-band — solves binary bloat, but track before the first commit · without LFS, keep binaries in object storage/registries with the repo holding references · a submodule stores a gitlink (mode 160000) pinning one commit of another repo + a .gitmodules map; clones don't fetch its content (needs --recurse-submodules/update --init); prefer package managers for shared code unless exact-commit pinning is essential.

Next: Module 15 — Hooks, Maintenance, and Git at Scale — the final module. Client and server hooks to automate and enforce, gc/fsck and packfiles for repository health, shallow and partial clones, and the monorepo realities that stretch Git to its limits. Everything from Modules 1–14 comes together at scale.
Spotted a mistake or want something added? Send me a note.