Best Practices: Habits That Keep a Repo Healthy
This chapter collects the conventions that keep a repo healthy: commit messages, branching rules, recovery patterns, and the anti-patterns that bite teams.
Commit Messages
A good commit message has two parts: a short subject, and an optional body.
The Subject
- One line, under ~72 characters.
- Imperative mood ("Add rate limit", not "Added rate limit" or "Adds").
- Capitalize the first word.
- No period at the end.
- Specific, not generic ("Fix off-by-one in login throttle", not "Fix bug").
Bad:
fixed stuff
wip
more changes
update readme
Better:
Add rate limit to login endpoint
Fix off-by-one in window boundary check
Document environment variables in README
The Body
A blank line after the subject, then paragraphs that explain why and what changed if not obvious. Wrap at ~72 characters. Reference issue numbers at the bottom.
Add rate limit to login endpoint
The login endpoint was unbounded, which let a script iterate 10k
email/password pairs per second from a single IP. Added a sliding
window limiter of 10 attempts per 15 minutes per IP.
Chose sliding window over fixed window to avoid the burst-at-boundary
case where an attacker sends 20 requests in the last and first seconds
of two adjacent windows.
Fixes #342.
The body is where you encode the why. Code shows the what. Six months from now, git blame lands on this commit and you need to remember the reasoning.
Conventional Commits (Optional)
Some teams use a prefix convention like Conventional Commits:
feat: add password reset flow
fix: handle empty email in login validation
docs: document rate limit behavior
refactor(auth): extract session validation
chore: bump dependencies
Useful if you auto-generate changelogs or release notes from commits. Optional otherwise. Pick a convention, write it in CONTRIBUTING.md, enforce with a commit-msg hook if you care.
Atomic Commits
One logical change per commit. A good commit:
- Does one thing.
- Builds and tests pass on this commit.
- Has a subject that describes the whole change.
- Can be reverted cleanly without unrelated side effects.
Atomic commits make git bisect work, make git revert surgical, and make code review tractable. They're worth the extra discipline.
When you've accumulated five WIP commits figuring out a feature, interactive rebase them into one or two atomic commits before the PR (Chapter 6).
Branch Naming
Pick a convention and stick with it. A common one:
feature/short-description # new work
fix/short-description # bug fixes
chore/short-description # build, CI, deps
docs/short-description # documentation
refactor/short-description # code cleanup, no behavior change
Use hyphens, lowercase. Include ticket numbers only if your tooling benefits. Branch names are noise once the PR is merged; don't over-engineer.
The .gitignore
Every project has one. A few principles:
- Generate from a language template (github.com/github/gitignore) and tweak.
- Ignore build artifacts, not source files.
- Ignore editor metadata (
.vscode/,.idea/) unless the team has agreed to share project settings. - Ignore secrets (
.env,*.key,*.pem). Assume.gitignorealone is insufficient; also use pre-commit hooks or secret scanners.
If a file is already tracked and you add it to .gitignore, git keeps tracking it. Remove it explicitly:
git rm --cached accidentally_tracked.log
Never Commit Secrets
Once a secret is in git history, assume it's compromised. Rotation, not rewriting history, is the real fix.
Defensive habits:
- Pre-commit scanners. Tools like
gitleaks,trufflehog, or thedetect-secretspre-commit hook catch common patterns. - Server-side scanning. GitHub and GitLab both scan pushes for leaked credentials from common providers.
- Per-environment
.envfiles, never checked in. Commit a.env.examplewith placeholder values. - Vault-style secret management for production.
If you do commit a secret:
- Rotate it immediately. Assume the secret is public.
- Rewrite history with
git-filter-repoto remove it. - Force-push and have everyone re-clone.
- Still assume the secret is public. Caches and forks exist.
Keeping History Linear (or Not)
Three popular merge styles:
- Squash-merge for PRs, linear history on main. Each PR becomes one commit. Clean, searchable, easy for
git bisect. - Merge commits for PRs. All commits preserved, visible branch shape. Richer history, noisier graph.
- Rebase-merge for PRs. Linear history, full commits preserved. Clean but requires every intermediate commit to be good.
Squash-merge is a sensible default. Pick one per repo, document it, and don't mix.
Recovery Recipes
Commands you'll reach for eventually. Print this list somewhere.
"I committed to the wrong branch"
git switch correct-branch
git cherry-pick <sha>
git switch wrong-branch
git reset --hard HEAD~1
"I committed a secret"
# Rotate the secret FIRST. Then:
git-filter-repo --path secret-file --invert-paths
git push --force-with-lease
# Tell teammates to re-clone.
"I messed up a rebase"
git reflog
# find the commit from before the rebase
git reset --hard HEAD@{5} # or whichever entry
"I deleted a branch I needed"
git reflog --all | grep branch-name
git switch -c branch-name <sha-from-reflog>
"I force-pushed and lost remote work"
# If the remote has a reflog (GitHub: check the API or ask support):
# Otherwise:
git fetch origin
# Hope someone has the old commit locally and can push it.
Prevention beats recovery here. --force-with-lease over --force, always.
"The repo is huge and cloning is slow"
git clone --depth 1 git@host:repo.git # shallow clone, latest commit only
git clone --filter=blob:none git@host:repo.git # partial clone (lazy blobs)
Shallow clones can't push to most remotes without extra work. Use for CI builds, not development.
Hygiene Tasks
Worth running periodically on a repo you maintain:
git gc # garbage collect
git fetch --prune # drop dead remote-tracking branches
git branch --merged main | grep -v main | xargs git branch -d # delete merged branches
The last line is your "clean up branches" utility. Read the output before running xargs; once branches are gone, they're only in the reflog for 90 days.
Anti-Patterns
Force-push to main. Never. Anyone pulling has broken history.
Committing generated files. Build artifacts, node_modules/, dist/, vendor/ (unless vendoring is deliberate). Use .gitignore.
"Fix typo" commits all the way down. Interactive rebase and squash before opening a PR.
Commits that include "WIP" or "save point" in the subject. Fine during development. Clean up before the branch becomes public.
Long-running feature branches. Branches that outlive a week accumulate conflicts at an increasing rate. Merge main in often, or split the work.
Uninformative commit messages. "update code", "fixes", "asdf". These are a debt you pay when debugging in six months.
git add . at the root of a dirty repo. You just committed a .env file. Read git status before git add.
Skipping hooks with --no-verify. Hooks exist for a reason. If a hook is too slow or noisy, fix the hook. Bypassing it by habit defeats the purpose.
Amending or rebasing shared commits. Everyone downstream has broken history. Revert instead.
Storing large binaries or secrets in history. They're there forever. Use LFS or external storage; rotate secrets.
Where to Go From Here
You have the commands, the internals, the recovery recipes, and the team practices. The next level is depth in specific areas:
- Internals and the plumbing commands: Pro Git book, especially chapters 10 (Internals) and 7 (Reset Demystified).
- Advanced workflow discussions: Martin Fowler on Patterns for Managing Source Code Branches.
- Interactive practice: Learn Git Branching for the graph; Oh My Git! for the fundamentals.
- Recovery recipes: Dangit, Git!?! is a short catalog of "I broke it" scenarios with the fix.
Build something. Break it on purpose. git reflog your way out. That's how git stops being a set of cargo-culted commands and becomes a tool you trust.