Skip to content
Back to Build Tutorials

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.