Tools: Update: Git Workflow Best Practices 2025: Team-Proven Strategies

Tools: Update: Git Workflow Best Practices 2025: Team-Proven Strategies

Git Workflow Best Practices 2025: Team-Proven Strategies

Choose Your Branching Strategy

GitHub Flow (Recommended for most teams)

Gitflow (For release-based projects)

Trunk-Based Development

Write Better Commit Messages

Examples

Branching Naming Conventions

Rebase vs Merge: When to Use Each

Merge (preserves history)

Rebase (clean linear history)

The practical rule

PR Review Best Practices

As the author

As the reviewer

Automate Git Hygiene with Hooks

Pre-commit hook: run linter and formatter

Commit-msg hook: enforce Conventional Commits

Pre-push hook: run tests

Keeping History Clean

Interactive rebase to clean up before PR

Amend the last commit (before pushing)

Fix a commit buried in history

.gitignore Essentials

CI/CD Integration

The Non-Negotiables

Level Up Your Dev Workflow Bad Git hygiene compounds. A messy history becomes a messy codebase becomes a messy team. This guide covers the practices that teams have standardized on in 2025 — from commit conventions to branching models to automated enforcement. There's no universal right answer, but here are the three most common models: Simple: one main branch, short-lived feature branches, deploy from main. Best for: Continuous deployment, small-to-medium teams, SaaS products. Adds develop, release/*, and hotfix/* branches: Best for: Mobile apps, libraries with versioned releases, enterprise software. Everyone commits directly to main (with feature flags for incomplete features). Short-lived branches (< 1 day) allowed. Best for: High-velocity teams with strong CI/CD and test coverage. Follow the Conventional Commits specification. It's machine-readable (useful for changelogs) and human-readable: Enforce this with Commitlint + a git hook. Consistent branch names help everyone at a glance: Pattern: <type>/<ticket-id>-<short-description> Enforce with a pre-push hook or CI check. Creates a merge commit. History shows exactly when branches joined. Better for public branches where others may have based work on yours. Replays your commits on top of the target branch. History looks linear and clean. Never rebase shared/public branches — it rewrites commit hashes. Use Husky to run checks automatically: Commit .env.example with placeholder values so teammates know what variables are needed: Every push and PR should automatically: Sample GitHub Actions workflow: Good Git workflow isn't about being strict for its own sake. It's about making collaboration predictable and making future-you able to understand what past-you was thinking. Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers. 🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

main ├── feature/user-auth ← branch, PR, merge, delete ├── fix/login-timeout ← branch, PR, merge, delete └── feature/dark-mode ← branch, PR, merge, delete main ├── feature/user-auth ← branch, PR, merge, delete ├── fix/login-timeout ← branch, PR, merge, delete └── feature/dark-mode ← branch, PR, merge, delete main ├── feature/user-auth ← branch, PR, merge, delete ├── fix/login-timeout ← branch, PR, merge, delete └── feature/dark-mode ← branch, PR, merge, delete main ← production develop ← integration ├── feature/x ← branch from develop ├── release/1.2 ← branch from develop, merge to main + develop └── hotfix/1.2.1 ← branch from main, merge to main + develop main ← production develop ← integration ├── feature/x ← branch from develop ├── release/1.2 ← branch from develop, merge to main + develop └── hotfix/1.2.1 ← branch from main, merge to main + develop main ← production develop ← integration ├── feature/x ← branch from develop ├── release/1.2 ← branch from develop, merge to main + develop └── hotfix/1.2.1 ← branch from main, merge to main + develop <type>(<scope>): <short summary> [optional body] [optional footer(s)] <type>(<scope>): <short summary> [optional body] [optional footer(s)] <type>(<scope>): <short summary> [optional body] [optional footer(s)] # Good git commit -m "feat(auth): add OAuth2 login with Google" git commit -m "fix(api): handle 429 rate limit response correctly" git commit -m "docs(readme): add Docker setup instructions" # Bad — vague and unhelpful git commit -m "fix stuff" git commit -m "WIP" git commit -m "changes" # Good git commit -m "feat(auth): add OAuth2 login with Google" git commit -m "fix(api): handle 429 rate limit response correctly" git commit -m "docs(readme): add Docker setup instructions" # Bad — vague and unhelpful git commit -m "fix stuff" git commit -m "WIP" git commit -m "changes" # Good git commit -m "feat(auth): add OAuth2 login with Google" git commit -m "fix(api): handle 429 rate limit response correctly" git commit -m "docs(readme): add Docker setup instructions" # Bad — vague and unhelpful git commit -m "fix stuff" git commit -m "WIP" git commit -m "changes" feature/VIC-123-user-authentication fix/VIC-456-broken-login-redirect chore/upgrade-dependencies-march-2025 docs/update-api-reference hotfix/critical-payment-bug feature/VIC-123-user-authentication fix/VIC-456-broken-login-redirect chore/upgrade-dependencies-march-2025 docs/update-api-reference hotfix/critical-payment-bug feature/VIC-123-user-authentication fix/VIC-456-broken-login-redirect chore/upgrade-dependencies-march-2025 docs/update-api-reference hotfix/critical-payment-bug git checkout main git merge feature/new-login git checkout main git merge feature/new-login git checkout main git merge feature/new-login git checkout feature/new-login git rebase main git checkout feature/new-login git rebase main git checkout feature/new-login git rebase main

What this PR does

[1-3 bullet points explaining the change]

Why[Business or technical motivation]

Testing- [ ] Unit tests pass- [ ] Manual test: login with Google → redirects correctly- [ ] Checked mobile view

Screenshots (if UI change)

[before/after]

Command

Copy

$

What this PR does

[1-3 bullet points explaining the change]

Why[Business or technical motivation]

Testing- [ ] Unit tests pass- [ ] Manual test: login with Google → redirects correctly- [ ] Checked mobile view

Screenshots (if UI change)

[before/after]

Command

Copy

$

What this PR does

[1-3 bullet points explaining the change]

Why[Business or technical motivation]

Testing- [ ] Unit tests pass- [ ] Manual test: login with Google → redirects correctly- [ ] Checked mobile view

Screenshots (if UI change)

[before/after]

Code Block

Copy

nit: could use Array.from() here for clarity blocking: this will crash if user.profile is null q: why is this using localStorage instead of sessionStorage? nit: could use Array.from() here for clarity blocking: this will crash if user.profile is null q: why is this using localStorage instead of sessionStorage? nit: could use Array.from() here for clarity blocking: this will crash if user.profile is null q: why is this using localStorage instead of sessionStorage? npm install --save-dev husky npx husky init npm install --save-dev husky npx husky init npm install --save-dev husky npx husky init # .husky/pre-commit npx lint-staged # .husky/pre-commit npx lint-staged # .husky/pre-commit npx lint-staged // package.json { "lint-staged": { "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md,css}": ["prettier --write"] } } // package.json { "lint-staged": { "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md,css}": ["prettier --write"] } } // package.json { "lint-staged": { "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md,css}": ["prettier --write"] } } # .husky/commit-msg npx --no -- commitlint --edit $1 # .husky/commit-msg npx --no -- commitlint --edit $1 # .husky/commit-msg npx --no -- commitlint --edit $1 # .husky/pre-push npm test -- --passWithNoTests # .husky/pre-push npm test -- --passWithNoTests # .husky/pre-push npm test -- --passWithNoTests # Squash last 3 commits git rebase -i HEAD~3 # Squash last 3 commits git rebase -i HEAD~3 # Squash last 3 commits git rebase -i HEAD~3 git commit --amend # Opens editor to change message git commit --amend --no-edit # Keep message, add staged changes git commit --amend # Opens editor to change message git commit --amend --no-edit # Keep message, add staged changes git commit --amend # Opens editor to change message git commit --amend --no-edit # Keep message, add staged changes # Stage the fix git add path/to/fix.js # Create a fixup commit targeting the hash git commit --fixup <commit-hash> # Auto-squash it in git rebase -i --autosquash HEAD~5 # Stage the fix git add path/to/fix.js # Create a fixup commit targeting the hash git commit --fixup <commit-hash> # Auto-squash it in git rebase -i --autosquash HEAD~5 # Stage the fix git add path/to/fix.js # Create a fixup commit targeting the hash git commit --fixup <commit-hash> # Auto-squash it in git rebase -i --autosquash HEAD~5 # Secrets and environment .env .env.local .env.*.local # Dependencies node_modules/ vendor/ # Build output dist/ build/ .next/ # Editor files .vscode/settings.json .idea/ *.swp # OS artifacts .DS_Store Thumbs.db # Logs *.log logs/ # Secrets and environment .env .env.local .env.*.local # Dependencies node_modules/ vendor/ # Build output dist/ build/ .next/ # Editor files .vscode/settings.json .idea/ *.swp # OS artifacts .DS_Store Thumbs.db # Logs *.log logs/ # Secrets and environment .env .env.local .env.*.local # Dependencies node_modules/ vendor/ # Build output dist/ build/ .next/ # Editor files .vscode/settings.json .idea/ *.swp # OS artifacts .DS_Store Thumbs.db # Logs *.log logs/ # .env.example DATABASE_URL=postgresql://localhost:5432/mydb API_KEY=your-api-key-here # .env.example DATABASE_URL=postgresql://localhost:5432/mydb API_KEY=your-api-key-here # .env.example DATABASE_URL=postgresql://localhost:5432/mydb API_KEY=your-api-key-here name: CI on: pull_request: branches: [main] push: branches: [main] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm run lint - run: npm test - run: npm run build name: CI on: pull_request: branches: [main] push: branches: [main] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm run lint - run: npm test - run: npm run build name: CI on: pull_request: branches: [main] push: branches: [main] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm run lint - run: npm test - run: npm run build - Before opening a PR: rebase your feature branch onto main to catch conflicts early - When merging a PR: use squash merge (one clean commit) or merge commit (preserves context) depending on team preference - Never force-push to main - Keep PRs small: under 400 lines changed is ideal - Link the ticket/issue - Self-review before requesting others - Review within 24 hours (set team SLA) - Comment on the code, not the person - Use prefixes: nit: (optional style), blocking: (must fix), q: (genuine question) - pick — keep the commit - squash (or s) — combine with previous - reword (or r) — keep but edit the message - drop (or d) — remove completely - Run linting (eslint, flake8, etc.) - Run tests (unit + integration) - Check types (tsc --noEmit for TypeScript) - Build the project (catch build-time errors) - Never commit secrets — use environment variables and .gitignore - Never force-push to main — use protected branch rules - One logical change per commit — makes git bisect and reverting possible - Write commit messages in imperative mood — "Add login" not "Added login" - Delete merged branches — keep the repo clean