Git Workflow Best Practices | Branching· PR Reviews
이 글의 핵심
Good Git workflow keeps your team moving fast without stepping on each other. This guide covers the branching strategies, commit conventions, PR practices, and CI integration that high-performing teams use.
Why Git workflow matters (before the diagrams)
A shared Git workflow is not ceremony for its own sake. It is the contract that lets multiple developers integrate work without re-learning the process every time someone joins. The sections below compare common models, then move into day-to-day tactics: how you name branches, how you write commits, how you integrate changes, and how you use automation. Treat this as a menu: adopt what fits your release cadence and team size, and do not import GitFlow’s full complexity unless you are actually shipping multiple supported versions in parallel.
Comparing Git workflows: Git Flow, GitHub Flow, GitLab Flow, and trunk-based
Mental model: where does “integrate” happen?
Think of every workflow as answering two questions: how long do branches live, and where do merges land? The following high-level picture is not literal output from git log, but it helps compare shapes.
Git Flow (simplified)
main ──●───●───●─── (production releases only)
develop ●─●─●─●─●─● (integration; feature branches merge here)
\ / feature branches: short- or medium-lived
●─●
GitHub / GitLab “simple” Flow
main ●──●──●──●── (always deployable; feature → PR → main)
\ /\ /
●● ●● feature branches, frequent merges
Trunk-based
main ●●●●●●●● (everyone targets main quickly; feature flags)
↑ tiny branches or direct commits, heavy CI
Git Flow (Vincent Driessen’s model) uses long-lived develop plus main, with feature/*, release/*, and hotfix/*. It shines when you must maintain several production versions and coordinate releases with QA windows. The cost is cognitive: developers must know which line to target, and merge-back steps are easy to get wrong. Many SaaS teams adopted Git Flow a decade ago and later simplified because they never cut “releases” the way desktop software does.
GitHub Flow is the default answer for “one production line, deploy from main”: branch off main, open a pull request, review, merge, delete the branch. It pairs naturally with continuous deployment and small batches of work. This article’s earlier examples are largely GitHub Flow–shaped.
GitLab Flow is not one diagram; it is GitHub Flow + environment branches or upstream/downstream when you need them (e.g. production lags staging, or a merge train). Document who promotes what, or you will get two sources of truth for “what is live.”
Trunk-based development keeps integration intervals very short—often hours, not days—and relies on feature flags, strangler patterns, and strong CI to keep main green. It is the honest default for orgs that deploy many times per day, but it is not magic: without test discipline, you only ship bugs faster.
Pragmatic team note: I have seen a team “do GitFlow on paper” but effectively merge everything to develop in huge batches, then fight a two-week release branch. The workflow name did not match behavior. If your main and develop are usually identical, you are not really using Git Flow—simplify.
Branching strategy: a practical field guide
- Pick one primary integration branch (almost always
main) and make its meaning obvious: “what we can deploy.” - Standardize names so
git branch -areads like a todo list, not a junk drawer:feature/…,fix/…,chore/…, optionalTICKET-…prefix. - Time-box feature branches: if a branch is older than a couple of sprints, the work is probably too big or blocked; split or pair-program to unblock.
- Align with release reality: if you only ship on Fridays, you still can use GitHub Flow, but you may add labels and milestone discipline so half-finished work does not sit in limbo.
- Document exceptions: hotfix path, who can push to protected branches, and whether force-push is ever allowed (usually only on personal feature branches after rebase).
GitHub Flow (recommended for most product teams)
main (always deployable)
├── feature/user-authentication
├── fix/login-redirect-bug
└── chore/update-dependencies
# 1. Create branch from main
git checkout main && git pull
git checkout -b feature/user-authentication
# 2. Make commits
git add .
git commit -m "feat(auth): add JWT token validation"
# 3. Push and open PR
git push -u origin feature/user-authentication
gh pr create --title "Add user authentication" --body "..."
# 4. Review, fix, merge to main
# (merge/squash/rebase in GitHub UI)
# 5. Delete branch after merge
git branch -d feature/user-authentication
Trunk-based (high-frequency delivery)
main
← feature flags hide incomplete work
← CI must pass on (almost) every commit
← deploy from green main as often as you can
Use trunk-based when: CI is trustworthy, you can slice work into small, safe increments, and the organization accepts reverting or fix-forward instead of long stabilization branches.
Branch naming
# Format: type/short-description
feature/user-authentication
feature/payment-stripe-integration
fix/login-redirect-loop
fix/api-rate-limit-headers
hotfix/critical-sql-injection
chore/upgrade-react-18
docs/api-authentication-guide
refactor/extract-payment-service
test/add-checkout-integration-tests
# With ticket reference
feature/JIRA-123-user-authentication
fix/GH-456-login-redirect
# Rules:
# - lowercase, hyphens not underscores
# - short but descriptive (3-5 words)
# - prefix with type
Conventional Commits (and why your future self cares)
Conventional Commits is a small grammar for messages: type(scope): description, optional body and footer for BREAKING CHANGE. It is not only aesthetics—release tooling (semantic-release, release-please, changesets) can derive version bumps and changelogs from it, and git log --grep becomes usable.
Format: type(scope): description
feat: new feature (triggers minor version bump in semver tooling)
fix: bug fix (triggers patch version bump)
docs: documentation changes only
refactor: code change that neither fixes bug nor adds feature
test: adding or updating tests
chore: build, CI, dependency updates
style: formatting, no logic change
perf: performance improvement
revert: revert a previous commit
(scope): optional, names the module affected
BREAKING CHANGE: footer triggers major version bump
Examples:
git commit -m "feat(auth): add Google OAuth login"
git commit -m "fix(api): handle null response from payment provider"
git commit -m "docs(readme): add Docker setup instructions"
git commit -m "refactor(database): extract connection pooling to module"
git commit -m "test(auth): add JWT expiry edge case tests"
git commit -m "chore: upgrade TypeScript to 5.4"
git commit -m "perf(search): add index on posts.created_at"
# Breaking change (triggers major version bump)
git commit -m "feat(api)!: change user ID from integer to UUID
BREAKING CHANGE: User IDs are now UUIDs (string) instead of integers.
All API consumers must update their client code."
Honest tip: If only one person on the team writes conventional messages and everyone else writes “fix,” you still get 80% of the benefit by enforcing type and a scope in code review, then gradually tightening the rules.
Commit quality: small stories, not noise
# ✅ Good commits:
feat(auth): add JWT refresh token rotation
fix(cart): prevent duplicate items when clicking Add to Cart twice
refactor(user): extract address validation to AddressValidator class
test(payment): add integration tests for Stripe webhook handling
# ❌ Bad commits:
WIP
fix stuff
update
asdf
changes
more work on the thing
.
Principles that survive code review arguments:
- One commit should express one coherent intent (atomic where practical).
- First line ≤ 72 characters; body explains why more than how.
- Do not commit with failing tests; CI is a backstop, not a babysitter.
- If the message needs “and” twice, you probably have two commits.
Rebase vs merge: strategies that do not start religious wars
On a shared branch (e.g. main), the rule is simple: never rewrite history other people depend on. On your feature branch, rebase is a hygiene tool: it replays your commits on top of an updated main so the PR does not contain unrelated merge noise.
| Situation | Reasonable default |
|---|---|
Update feature branch with latest main | git fetch origin && git rebase origin/main (or merge main in if your team dislikes rebase) |
Integrate a finished PR to main (GitHub) | Squash merge for one story = one commit; or rebase and merge if every commit is meaningful and reviewed |
| Long-lived feature + many collaborators | Often merge main into the branch periodically to reduce painful mega-conflicts at the end |
Merge commit on main: preserves the exact moment of integration and can help read “what shipped together” in tools like git log --merges, at the cost of a busier log. Squash:clean narrative on main, but you lose granular commit history unless you require contributors to keep a clean branch (many teams do not).
Rebase gotcha: if you rebase after others have based work on your old commits, you force them to realign. Communicate, or rebase when the branch is still yours alone.
Quick recipe (feature branch, linear preference):
git fetch origin
git checkout feature/my-work
git rebase origin/main
# resolve conflicts per commit if needed, then
git push --force-with-lease
--force-with-lease is the safety belt: it refuses to clobber new upstream work you have not seen.
PR optimization: make review cheap for humans
Time spent clarifying a PR in chat is time not spent shipping. A good description answers what, why, how to verify, and what can wait.
A solid PR description template
## What
Add JWT refresh token rotation to the auth system.
## Why
Currently, access tokens don't expire — a stolen token grants permanent access.
This PR adds 15-minute access tokens with refresh token rotation.
## Changes
- `AuthService.login()` now returns both access and refresh tokens
- `AuthService.refresh()` validates refresh token and issues new pair
- Refresh tokens are stored in HttpOnly cookies (not localStorage)
- Added `POST /auth/refresh` endpoint
## Testing
- Run `npm test` — all 47 auth tests pass
- Manual: login → wait 15min → verify auto-refresh works
- Edge case: revoked refresh token → verify 401 returned
## Notes
The refresh token table migration runs automatically in deployment.
No manual steps needed.
PR size: the real reason “small PRs” is repeated everywhere
Ideal PR size: 200-400 lines changed
Maximum for reasonable review: ~800 lines
Too large? Split by:
- Component/layer: API changes in one PR, UI in another
- Feature flag: merge implementation behind flag, enable in follow-up
- Stacked PRs: PR2 targets PR1's branch (merged together)
Field evidence: On one team I worked with, “big bang” PRs around migrations usually meant one approver skimming the diff and a production incident later. When we split schema migration from application code with a compat layer in between, review quality went up and rollbacks got easier.
Review checklist (still worth printing)
Before requesting review:
[ ] Self-review your own diff (you'll catch obvious issues)
[ ] Tests pass locally
[ ] No debug console.log left
[ ] No TODO comments left (or they're tracked in issues)
[ ] No sensitive data (passwords, tokens, keys)
[ ] PR description explains the why
When reviewing:
[ ] Does it solve the stated problem?
[ ] Are there edge cases not handled?
[ ] Is the code readable? Would I understand it in 6 months?
[ ] Are there security implications?
[ ] Do the tests actually test the right things?
[ ] Does it follow team conventions?
Automation is part of “optimization”: when lint, typecheck, and tests are required checks, humans argue about product and design, not semicolons.
Merge strategies (on the default branch)
# Strategy 1: Squash merge (recommended for most teams)
# All PR commits → 1 commit on main
git merge --squash feature/user-auth
git commit -m "feat(auth): add user authentication (#123)"
# Main history: one commit per feature, clean and readable
# Strategy 2: Rebase merge (linear history, all commits)
git rebase main feature/user-auth
git checkout main && git merge feature/user-auth # Fast-forward
# Main history: all commits, but no merge commits
# Strategy 3: Regular merge (merge commit)
git merge feature/user-auth
# Creates: "Merge pull request #123 from feature/user-auth"
# History shows when branches were integrated
Team policy idea: if you use squash, ask authors to make the squash title a good Conventional Commit; otherwise you get “Update stuff (#999)” on main forever.
Conflict resolution: patterns that scale
Conflicts are not Git punishing you; they are overlapping edits in the same text. A predictable response beats heroics.
- Reproduce locally with
git fetch+ merge or rebase, same as the PR will do. Do not “resolve” on GitHub’s editor for non-trivial files unless you enjoy second-round failures. - Understand the hunk before choosing ours/theirs. Blindly “accept all incoming” in large files is how production configs get wiped.
- Rename/move conflicts need extra care: Git sometimes reports low-level file fights when the intent was a directory restructure. Resolve the structure first, then the content.
- Lock binary assets (images, some PDFs) with team conventions—Git cannot merge them meaningfully, so you pick a version and verify visually.
- After resolution:
git addthe resolved files,git rebase --continue(or complete the merge), then run tests again—conflict markers gone does not mean logic is right.
Pattern: integration branch for gnarly work. When two teams touch the same module for a quarter, a short-lived collab branch (or one owner sequencing merges) can beat endless ping-pong conflicts on main.
# During rebase, if you get stuck in conflict hell:
git status # which files are unmerged
# edit files, remove <<<<<<< markers
git add < files >
git rebase --continue
# If you need to abort and rethink:
git rebase --abort
Git hooks: pre-commit, pre-push, and keeping CI fast
Hooks are local automation that runs at lifecycle points. They do not replace CI (attackers or tired humans can skip hooks with --no-verify); they shift left the fastest checks.
pre-commit: formatters, linters, secret scanners (e.g. detect private keys in patches). Keep this stage under ~tens of seconds or people will bypass it.pre-push: heavier checks if you want—unit tests for touched packages, typecheck, or a quicknpm test. Balance: slow hooks train people to--no-verify.- Commit-msg: verify Conventional Commit format before the message is baked in.
Tooling you see in the wild: pre-commit.com (framework, language-agnostic), Husky (Node), lefthook (fast, multi-language). Store hook configuration in the repo; do not ask every developer to hand-edit .git/hooks/.
Example sketch (Husky-style pre-commit idea): run lint-staged so only changed files pay the cost, not the entire monorepo on every save.
Honest experience: I once worked somewhere hooks ran a full integration suite pre-push. Everyone used --no-verify, and the problem moved to “broken main.” The fix was a staged approach: pre-commit = format + quick lint, CI = full matrix.
Monorepo Git strategy (without losing your mind)
Monorepos put many packages or apps in one Git repository. Git itself handles scale reasonably well; tooling and ownership are where teams struggle.
- Path-based scope: use
CODEOWNERS(GitHub/GitLab) so the right people review the right trees. - Selective builds: CI should map changed paths → affected projects (
nx,turborepo,Bazel,buck, custom scripts), notnpm testat the root for every one-line fix. - Shared versioning vs independent: decide whether you ship one version of everything or use per-package semver; this affects branching and tagging (e.g. changesets for libraries).
- Long-lived
main: still the integration spine; “release branches per package” are possible but add overhead—often tags + automation suffice for libraries. - Binary and generated clutter: use
.gitignore, LFS for large media if needed, and do not commit build artifacts unless you have a strong reason (usually you do not).
Diagram (conceptual work graph): imagine CI as a fan-out from a single git push to N jobs, each aware of a subtree’s package.json or build graph. The Git part stays boring; the build graph is where you invest.
Real team stories (what actually happens)
-
The “WIP apocalypse”: A long-running feature branch with thirty commits titled
wipwas merged with squash. Six months later, nobody could explain a regression. Lesson: WIP is fine on a private branch, but rebase and clean before the final review window—or accept squash titles and body text as the only durable history and write them well. -
The Friday deploy freeze without a process freeze: A team froze production deploys for the holidays but kept merging to
main.maindrifted from production for two weeks, and the return week was all firefighting. Lesson: if code halts, define what “integration continues” means—sometimes a release branch that tracks prod is the honest model. -
Rebase + force-push on a shared branch: A contractor rewrote public history; another developer’s
git pullbecame a bad merge. Lesson: rebase is for branches you own; for collaboration, merge from main is sometimes the socially correct tool. -
Conventional Commits as policy without tooling: The messages looked great in week one, then drifted. Adopting commitlint in CI (warning at first, error later) fixed it faster than another doc nobody read.
-
Monorepo without path filters: A ten-minute CI for a typo in
READMEdestroyed goodwill. Path filters and caching turned CI back into a signal instead of noise.
None of this replaces trust and communication—it only makes dumb failures less frequent so you can spend political capital on the hard product trade-offs.
Protected branches and rules
# GitHub: Settings → Branches → Branch protection rules
Branch name pattern: main
Rules:
✅ Require pull request reviews before merging (1 reviewer min)
✅ Dismiss stale pull request approvals when new commits are pushed
✅ Require status checks to pass before merging (CI)
✅ Require branches to be up to date before merging
✅ Restrict who can push to matching branches
✅ Require linear history (prevents merge commits)
✅ Require signed commits (GPG)
Note on “linear history”: pairs naturally with rebase and squash; if you need merge commits for audit, turn this off and document the alternative.
Commit signing (GPG)
# Generate GPG key
gpg --full-generate-key # RSA, 4096 bits, no expiry
# Get key ID
gpg --list-secret-keys --keyid-format=long
# sec rsa4096/3AA5C34371567BD2 2024-01-01
# Configure Git to sign commits
git config --global user.signingkey 3AA5C34371567BD2
git config --global commit.gpgsign true # Sign all commits
# Export public key → add to GitHub account
gpg --armor --export 3AA5C34371567BD2
# Verify: signed commits show "Verified" badge on GitHub
.gitignore essentials
# .gitignore
# Secrets — NEVER commit
.env
.env.local
.env.*.local
*.pem
*.key
credentials.json
# Dependencies
node_modules/
venv/
.venv/
__pycache__/
# Build output
dist/
build/
.next/
out/
# IDE
.vscode/settings.json # Project settings OK, personal settings no
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
Useful Git aliases
# Add to ~/.gitconfig
[alias]
lg = log --oneline --graph --decorate --all
st = status -sb
co = checkout
br = branch
cp = cherry-pick
undo = reset HEAD~1 --mixed # Undo last commit, keep changes staged
wip = !git add -A && git commit -m "WIP"
unwip = !git log -n 1 | grep -q WIP && git reset HEAD~1
# Usage
git lg # Visual branch graph
git undo # Undo last commit (keep changes)
git wip # Quick WIP commit
CI/CD integration
# .github/workflows/ci.yml
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
ci:
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 # Code style
- run: npm run type-check # TypeScript
- run: npm run test:run # Unit tests
- run: npm run build # Production build
# ✅ PR can only merge when all checks pass
# ✅ Catches issues before they reach main
# ✅ Reviewers focus on logic, not style (linting handles that)
Related posts:
- [Git Interactive Rebase guide](/en/blog/git-interactive-rebase-practical-guide/
- [GitHub Actions CI/CD guide](/en/blog/github-actions-complete-guide/
Frequently asked questions (FAQ)
When do I use this material in real work?
Use it when you onboard engineers, when CI or review feels noisy, or when you are about to add a second team to the same repo. The comparisons above help you name what you already do, then trim what does not pay rent.
What should I read next?
- [Git interactive rebase (practical)](/en/blog/git-interactive-rebase-practical-guide/ — when you need to rewrite branch history safely.
- [GitHub Actions: CI/CD](/en/blog/github-actions-complete-guide/ — when you want automation that matches your branching rules.
How do I go deeper on merge strategies and server-side hooks?
The Git project’s documentation on branching workflows and your host’s docs (GitHub/GitLab protected branches and push rules) are authoritative for policy you cannot enforce in client-only hooks.
Related writing on this site
- [Git interactive rebase — pick, squash, fixup, conflicts, recovery](/en/blog/git-interactive-rebase-practical-guide/
- [GitHub Actions — workflows, deployment, automation](/en/blog/github-actions-complete-guide/
Keywords and related searches
Git, GitHub, DevOps, CI/CD, workflow, team, best practices, trunk-based development, Git Flow, Conventional Commits, rebase, merge, pull request, code review, monorepo, Git hooks