Overview
Git is your safety net. Think of commits as save points, branches as sandboxes, and history as documentation. AI agents generate code at high speed, and disciplined version control is the mechanism that keeps changes manageable, reviewable, and revertible.
When to use
Always use it. Every code change goes through git.
Core principles
Trunk-Based Development (recommended)
Keep main always deployable. Work in short-lived feature branches and merge back within 1-3 days. Long-lived development branches are a hidden cost: they diverge, create merge conflicts, and delay integration. DORA research consistently shows that trunk-based development is associated with high-performing engineering teams.
main ──●──●──●──●──●──●──●──●──●── (always deployable)
╲ ╱ ╲ ╱
●──●─╱ ●──╱ ← short-lived feature branches (1-3 days)
This is the recommended default. Teams using gitflow or long-lived branches can adapt these principles (atomic commits, small changes, descriptive messages) to their own branching model. Commit discipline matters more than the specific branching strategy.
- Dev branches are a cost. Every additional day a branch survives accumulates merge risk.
- Release branches are acceptable. When you need to stabilize a release while main continues to move forward.
- Feature flags > long branches。 Prefer deploying unfinished work behind flags rather than letting it sit on a branch for weeks.
1. Commit early, commit often
Every successful increment should have its own commit. Don’t accumulate a large amount of uncommitted changes.
Work pattern:
Implement slice → Test → Verify → Commit → Next slice
Not this:
Implement everything → Hope it works → Giant commit
Commits are save points. If the next change breaks things, you can immediately return to the last known good state.
2. Atomic Commits
Each commit should do only one logical thing:
# Good: Each commit is self-contained
git log --oneline
a1b2c3d Add task creation endpoint with validation
d4e5f6g Add task creation form component
h7i8j9k Connect form to API and add loading state
m1n2o3p Add task creation tests (unit + integration)
# Bad: Everything mixed together
git log --oneline
x1y2z3a Add task feature, fix sidebar, update deps, refactor utils
3. Descriptive messages
Commit messages explain why, not just what:
# Good: Explains intent
feat: add email validation to registration endpoint
Prevents invalid email formats from reaching the database.
Uses Zod schema validation at the route handler level,
consistent with existing validation patterns in auth.ts.
# Bad: Describes what's obvious from the diff
update auth.ts
Format:
<type>: <short description>
<optional body explaining why, not what>
Type:
feat— new featurefix— Bug fixrefactor— code changes that neither fix a bug nor add a featuretest— add or update testsdocs— documentation onlychore— tooling, dependencies, configuration
4. Keep concerns separate
Don’t mix formatting changes with behavior changes. Don’t mix refactoring with features. Each type of change should be a separate commit, and ideally a separate PR:
# Good: Separate concerns
git commit -m "refactor: extract validation logic to shared utility"
git commit -m "feat: add phone number validation to registration"
# Bad: Mixed concerns
git commit -m "refactor validation and add phone number field"
Separate refactoring from feature development. Refactoring changes and feature changes are two different changes and should be committed separately. This makes each change easier to review, roll back, and understand in history. Small cleanups (such as variable renames) can be included in a feature commit at the reviewer’s discretion.
5. Control change size
Aim for about 100 lines per commit/PR. Changes over about 1000 lines should be split. For how to split large changes, see code-review-and-quality for splitting strategies.
~100 lines → Easy to review, easy to revert
~300 lines → Acceptable for a single logical change
~1000 lines → Split into smaller changes
Branching strategy
Feature Branches
main (always deployable)
│
├── feature/task-creation ← One feature per branch
├── feature/user-settings ← Parallel work
└── fix/duplicate-tasks ← Bug fixes
- Create a branch from
main(or the team’s default branch) - Keep branches short-lived (merge within 1-3 days); long-lived branches are a hidden cost
- Delete branches after merging
- For unfinished features, prefer feature flags over long-lived branches
Branch naming
feature/<short-description> → feature/task-creation
fix/<short-description> → fix/duplicate-tasks
chore/<short-description> → chore/update-deps
refactor/<short-description> → refactor/auth-module
Using Worktrees
For parallel AI agent work, use git worktrees to run multiple branches simultaneously:
# Create a worktree for a feature branch
git worktree add ../project-feature-a feature/task-creation
git worktree add ../project-feature-b feature/user-settings
# Each worktree is a separate directory with its own branch
# Agents can work in parallel without interfering
ls ../
project/ ← main branch
project-feature-a/ ← task-creation branch
project-feature-b/ ← user-settings branch
# When done, merge and clean up
git worktree remove ../project-feature-a
Benefits:
- Multiple agents can work on different features at the same time
- No need to switch branches (each directory has its own branch)
- If an experiment fails, just delete the worktree without losing anything else
- Changes are isolated from each other until explicitly merged
Savepoint pattern
Agent starts work
│
├── Makes a change
│ ├── Test passes? → Commit → Continue
│ └── Test fails? → Revert to last commit → Investigate
│
├── Makes another change
│ ├── Test passes? → Commit → Continue
│ └── Test fails? → Revert to last commit → Investigate
│
└── Feature complete → All commits form a clean history
This pattern means you lose at most one increment of work. If the agent goes off track,git reset --hard HEAD can take you back to the last successful state.
Change summary
After any modification, provide a structured summary. This makes review easier, enforces scope discipline, and exposes unexpected changes:
CHANGES MADE:
- src/routes/tasks.ts: Added validation middleware to POST endpoint
- src/lib/validation.ts: Added TaskCreateSchema using Zod
THINGS I DIDN'T TOUCH (intentionally):
- src/routes/auth.ts: Has similar validation gap but out of scope
- src/middleware/error.ts: Error format could be improved (separate task)
POTENTIAL CONCERNS:
- The Zod schema is strict — rejects extra fields. Confirm this is desired.
- Added zod as a dependency (72KB gzipped) — already in package.json
This pattern catches wrong assumptions early and gives reviewers a clear map of the changes. The "DIDN'T TOUCH" section is especially important—it shows you followed scope discipline and made no unrequested alterations.
Pre-Commit Hygiene
Before every commit:
# 1. Check what you're about to commit
git diff --staged
# 2. Ensure no secrets
git diff --staged | grep -i "password\|secret\|api_key\|token"
# 3. Run tests
npm test
# 4. Run linting
npm run lint
# 5. Run type checking
npx tsc --noEmit
Automate these checks with git hooks:
// package.json (using lint-staged + husky)
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}
Handling generated files
- Only commit generated files when the project expects it(e.g.
package-lock.json、Prisma migrations) - Do not commit build output(
dist/,.next/), environment files (.env), or IDE config (don't commit.vscode/settings.json) - Prepare
.gitignore, covering:node_modules/,dist/,.env,.env.local,*.pem
Debugging with Git
# Find which commit introduced a bug
git bisect start
git bisect bad HEAD
git bisect good <known-good-commit>
# Git checkouts midpoints; run your test at each to narrow down
# View what changed recently
git log --oneline -20
git diff HEAD~5..HEAD -- src/
# Find who last changed a specific line
git blame src/services/task.ts
# Search commit messages for a keyword
git log --grep="validation" --oneline
Releases and versioning
Commits areyourway of tracking changes;version is how youruserstrack it. From the moment something depends on your code (another team, a published package, a deployed client), "latest on main" is no longer an acceptable answer to the question "what version am I running, and is it safe to upgrade?" Version numbers and changelogs are the contract that answers this question.
Semantic Versioning
For anything with users, version according to MAJOR.MINOR.PATCH and let the numbers carry meaning:
MAJOR breaking change — consumers must change their code to upgrade
MINOR new functionality, backward-compatible — safe to upgrade
PATCH bug fix, backward-compatible — safe to upgrade
The number is a promise, so make the code worthy of it. A "patch" that changes behavior users depend on is a major change disguised as a patch (Hyrum's Law, see api-and-interface-design skill). When unsure whether a change is breaking, treat it as breaking; an unexpected major is far cheaper than a broken user.
Tag releases, and let the tag be the single source of truth
A release is an immutable point in history, not a moving branch. Tag it so it can always be reproduced:
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
Derive version numbers from the tag instead of manually editing scattered files, so that build artifacts, tags, and the changelog never contradict each other.
Maintain a changelog written for humans
A changelog is not git log. It is a curated, user-facing answer to "what changed, and do I care": grouped by Added / Changed / Fixed / Deprecated / Removed / Security , newest at the top, with each entry phrased around user impact rather than internal mechanics.
## [1.4.0] - 2025-06-12
### Added
- Bulk task import via CSV
### Fixed
- Timezone drift in recurring task due dates
### Deprecated
- `GET /v1/tasks/all` — use the paginated `GET /v1/tasks` (removal in 2.0)
Write the corresponding entry in the same change that makes the modification, recording it while the impact is still clear, rather than reconstructing it from commit archaeology at release time. Breaking changes need migration instructions and a deprecation window (following the deprecation-and-migration skill); actually performing the release is the responsibility of the shipping-and-launch skill, and this section is the versioning contract fed to it.
Common rationalization excuses
| Rationalization excuse | Reality |
|---|---|
| "Commit after the feature is complete" | One giant commit is nearly impossible to review, debug, or roll back. Commit every slice. |
| "The message doesn't matter" | The message is the documentation. Future you (and future agents) need to understand what changed and why. |
| "Squash everything later" | Squash destroys the development narrative. It is better to write clean incremental commits from the start. |
| "Branches add overhead" | Short-lived branches have almost no cost and prevent parallel work from colliding. Long-lived branches are the problem and should be merged within 1-3 days. |
| "Split this change later" | Large changes are harder to review, riskier to deploy, and harder to roll back. Split before committing, not after. |
| "I don't need .gitignore" | Until .env with production secrets gets committed. Set it up immediately. |
| "It's just a small fix, just bump the patch" | First look at what users can observe. A behavior change they depend on is major, no matter how small the diff. |
| "The changelog is the commit log" | Commits are written for you; the changelog is written for users and should be organized by impact. A changelog generated from raw commits drowns out the important points. |
| "Add the changelog at release time" | By then the impact can only be reconstructed from memory, and half of it is lost. Write the change and the entry together. |
Red flags
- Accumulating a large number of uncommitted changes
- Commit messages like "fix", "update", "misc"
- Formatting changes mixed with behavior changes
- There is no
.gitignore - Committed
node_modules/,.envor build artifacts - Long-lived branches significantly diverged from main
- Force-pushing to shared branches
- Breaking change hidden and released under a minor or patch version bump
- Releases without a tag, or the version number manually edited out of sync with the tag
- User-facing releases without a changelog entry, or the changelog just dumps commit messages into it
Verification
For each commit:
- The commit does only one logical thing
- The message explains why and follows the type convention
- Tests pass before committing
- No secrets in the diff
- No purely formatting changes mixed into behavior changes
-
.gitignoreCovers standard exclusions
For each release (anything with users):
- Version bump matches the change: breaking → major, additive → minor, fix → patch
- The release is tagged, and the version number is derived from the tag, not manually edited out of sync
- The Changelog has curated, human-readable entries for this version grouped by impact