Git Workflow
Pick a branching model and commit convention that scales from solo to team.
Why
The branching model determines how often you merge and how painful each merge is. Trunk-based development (short-lived branches, merge daily) keeps integration pain near zero because conflicts are small and fresh. Git-flow (feature/develop/release/hotfix branches) feels safe but creates long-lived branches that accumulate week-long merge conflicts. Conventional Commits (feat:, fix:, chore:) on top of either model gives you auto-generated changelogs and semantic version bumps for free. The choice matters less than the consistency: a team that mixes both models has the worst of each.
When
Before the second contributor joins. Takes 15 min to document. Revisit when the team grows past 5 or ships to prod daily.
How
1. Choose trunk-based for ≤10 people or daily deploys; git-flow for large teams with release managers - Trunk: branch off main, PR within 24h, merge back, delete branch - Document the choice in CONTRIBUTING.md so nobody guesses 2. Adopt Conventional Commits for the commit message format - feat: new feature, fix: bugfix, chore: tooling, refactor: no behavior change - Breaking change: feat!: or feat(scope)!: + BREAKING CHANGE: footer 3. Enforce the format with commitlint + husky - pnpm add -D @commitlint/cli @commitlint/config-conventional - .husky/commit-msg: npx --no-install commitlint --edit $1 4. Set up squash-merge in GitHub repo settings - Squash merges produce one clean commit per PR on main - The PR title becomes the commit subject — enforce conventional format there too 5. Add an auto-changelog or release-please step in CI - On merge to main, it bumps version + generates CHANGELOG.md from commit history
Example
# A feature PR lifecycle (trunk-based)
git checkout main && git pull
git checkout -b feat/checkout-session
# ... write code, commit with conventional format ...
git commit -m "feat(checkout): add stripe payment intent creation"
git push -u origin feat/checkout-session
# Open PR → CI runs → review → squash-merge
# main now has: "feat(checkout): add stripe payment intent creation (#42)"
# commitlint config (commitlint.config.js)
module.exports = { extends: ["@commitlint/config-conventional"] };Common Problems
- ⚠Long-lived feature branches that diverge for a week and require a 2-hour merge conflict session.
- ⚠Merge commits interleaving history so git bisect can't find the breaking commit in a binary search.
- ⚠Inconsistent commit messages ('stuff', 'fix', 'wip') making changelogs useless and git log unsearchable.
- ⚠Forgetting to pull main before branching, so the PR immediately conflicts with yesterday's merged work.
Best Practices
- ✓Set a max branch age of 2 days — if a PR is older, either split it or merge what's ready and continue in a new PR.
- ✓Use squash-merge exclusively so main has one commit per PR — bisect and revert both become trivial.
- ✓Run commitlint on the PR title (not just the commit) so squash-merged messages stay conventional.
- ✓Rebase onto main before opening the PR, not after review — this keeps the diff clean for reviewers.