Git Best Practices: Commits, Branching, and Workflow
Conventional commit standards, a comparison of trunk-based development, GitFlow, and GitHub Flow branching models, rebase versus merge trade-offs, commit signing, and monorepo versus polyrepo workflow guidance.
Introduction / Overview
Git is the substrate every other practice in this knowledge base is built on top of: CI/CD pipelines trigger from git events, code review happens on git-hosted pull requests, and rollback strategy (see CI/CD Patterns) is frequently implemented as a git revert. Getting the fundamentals right - commit hygiene, branching model, and history management - has outsized leverage because every downstream tool and process depends on git history being clean, meaningful, and trustworthy.
This document covers commit message conventions, a deliberate comparison of the three dominant branching models, the rebase-versus-merge decision, commit signing for supply chain integrity, and the specific workflow adjustments a monorepo requires versus a polyrepo.
When to use this guidance: Establishing or reviewing team git conventions, choosing a branching model for a new project, resolving disagreements about rebase vs. merge, or deciding between a monorepo and polyrepo structure.
Core Concepts
- Commit as the atomic unit of change: A commit should represent one logical, reviewable, revertable change. This is what makes
git bisect,git revert, and code review tractable. - Branch as a unit of isolation, not storage: A branch exists to isolate in-progress work from the trunk until it is ready to integrate - the shorter its lifetime, the less it costs in merge conflict risk.
- History as a communication artifact: Commit history is read far more often than it is written - by reviewers, by
git blame, by future maintainers, and by automated tooling (changelog generators, release automation). Optimize for the reader. - Provenance and integrity: In a supply-chain-security-conscious environment, a commit's authenticity (who really authored it) is as important as its content - this is what commit signing establishes.
Best Practices
1. Use Conventional Commits for Structured, Machine-Readable History
Rationale: A consistent commit message format enables automated changelog generation, semantic version bumping, and quick scanning of history by type of change.
feat(auth): add refresh token rotation
fix(api): correct off-by-one error in pagination cursor
Closes #482
BREAKING CHANGE: `page` query parameter is now 0-indexed instead of 1-indexed
Format: <type>(<optional scope>): <description>, with type from a fixed vocabulary:
| Type | Meaning | Triggers version bump (semantic-release style) |
|---|---|---|
feat | New feature | Minor |
fix | Bug fix | Patch |
docs | Documentation only | None |
refactor | Code change with no behavior change | None |
perf | Performance improvement | Patch |
test | Adding or correcting tests | None |
chore | Tooling, build config, dependency bumps | None |
BREAKING CHANGE footer | Any breaking API change | Major |
Why this works well in production: Tools like semantic-release or commitizen can parse this history directly into a changelog and the next version number, removing a manual, error-prone release step - directly feeding the pipeline described in CI/CD Patterns.
2. Write Small, Focused Commits With an Imperative Subject Line
Rationale: Small commits are reviewable, bisectable, and revertable independently. The imperative mood ("Add", "Fix", not "Added" or "Adds") matches the convention that a commit message completes the sentence "If applied, this commit will...".
# Good
Add rate limiting middleware to auth endpoints
# Avoid
Fixed some stuff in the auth module and also updated deps
Why this works well in production: When git bisect identifies a regression-introducing commit, a focused commit lets you understand and revert exactly the change responsible, without collateral damage to unrelated work bundled into the same commit.
3. Choose a Branching Model Deliberately
Rationale: The branching model is the workflow contract for the entire team; mismatches between the model and the team's actual release cadence produce friction (see CI/CD Patterns for the deployment-pipeline angle on this same decision).
| Dimension | Trunk-Based Development | GitHub Flow | GitFlow |
|---|---|---|---|
| Long-lived branches | None - trunk only, feature branches live hours to ~1-2 days | Short-lived feature branches merged via PR | main, develop, release/, hotfix/ - several long-lived branches |
| Typical merge frequency | Multiple times per day per developer | Daily to a few times per week | Batched per release cycle |
| Requires feature flags | Yes, for incomplete work | Often | Rarely (branches hide incomplete work) |
| Release versioning | Continuous, often tag-per-deploy | Continuous or on-demand tag | Explicit, batched version tags per release branch |
| Conflict resolution burden | Low (small, frequent diffs) | Low-medium | Medium-high (large accumulated diffs) |
| Best fit | Continuous deployment, high-velocity teams, SaaS | Web apps/APIs with continuous delivery, small-to-mid teams | Software with formal release trains - embedded, installed desktop software, regulated versioned releases |
| Hotfix path | Fix on trunk, deploy immediately | Fix via short-lived branch + PR, deploy immediately | Dedicated hotfix/* branch merged into both main and develop |
Recommendation for 2026: Default to trunk-based development for any team practicing continuous delivery or deployment; it is the branching model with the least merge-conflict overhead and pairs naturally with feature flags and progressive delivery (see CI/CD Patterns). Reserve GitFlow for products with genuinely batched, versioned release cycles where multiple release trains may be in flight and supported simultaneously (e.g., maintaining a previous major version while developing the next).
4. Understand Rebase vs. Merge and Apply Each Deliberately
Rationale: Rebase and merge solve different problems; using the wrong one for the context either rewrites shared history unsafely or clutters it unnecessarily.
| Dimension | git rebase | git merge |
|---|---|---|
| History shape | Linear - replays commits onto a new base | Preserves actual chronological/topological history, including a merge commit |
| Safe on shared/pushed branches | No (rewrites commit hashes) - safe only on local, unpushed, or exclusively-owned branches | Yes, always safe |
| Bisectability | Excellent - linear history is trivial to bisect | Can be harder if merge commits interleave unrelated work |
| Preserves "what actually happened" | No - history is rewritten as if developed serially | Yes - shows the real branch/merge topology |
| Typical use | Cleaning up a local feature branch before opening a PR (git rebase -i to squash/reorder) | Integrating a completed feature branch into trunk |
# Safe: rebasing your own, not-yet-pushed feature branch onto latest trunk
git fetch origin
git rebase origin/main
# Then merge into trunk via PR (often as a fast-forward or a single squash commit)
Why this works well in production: Rebasing locally before opening a PR produces a clean, linear, reviewable diff against the latest trunk. Never rebase a branch that others have already pulled from - this rewrites history they depend on and forces destructive git push --force on a shared branch, which should be treated as an incident, not routine practice.
git rebase -i opens an editor and requires a TTY, so it is for local, human-driven cleanup only - a script, CI job, or autonomous agent should never invoke it. Non-interactive equivalents exist for automation: git rebase --onto <newbase> <upstream> <branch> for scripted history transplantation, or GITSEQUENCEEDITOR=true git rebase -i <base> where the sequence must be accepted as-is with no reordering. See Autonomous, Least-Privilege, Portable Execution Environments for this requirement applied across all tooling, not just git.
5. Squash or Curate Commits at Merge Time for a Clean Trunk History
Rationale: A feature branch's in-progress commits ("wip", "fix typo", "address review comments") are noise in the permanent trunk history. Squashing at merge time keeps trunk history as one meaningful commit per logical change, matching Best Practice #1's conventional format.
# GitHub/GitLab "squash and merge" merge strategy, or manually:
git rebase -i origin/main # squash wip commits into one, write a conventional commit message
Why this works well in production: git log --oneline on trunk reads as a changelog, and git bisect only has to consider meaningful, complete units of change.
6. Sign Commits and Tags Cryptographically
Rationale: Commit signing (GPG or SSH-based, both supported natively by modern git and GitHub/GitLab) proves a commit was authored by the claimed identity, closing a supply-chain integrity gap - directly relevant to the Software Supply Chain Failures category in OWASP Top 10.
git config --global commit.gpgsign true
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git commit -S -m "feat(auth): add refresh token rotation"
Why this works well in production: Combined with branch protection rules requiring signed commits on main, this ensures every commit reaching trunk has verified provenance - a control increasingly expected in regulated and security-conscious organizations, and complementary to signed container images (see Docker Best Practices).
7. Adapt Workflow Explicitly for Monorepo vs. Polyrepo
Rationale: The choice between one repository containing many services versus one repository per service has direct git-workflow implications - commit scoping, CI triggering, and code ownership all change.
| Dimension | Monorepo | Polyrepo |
|---|---|---|
| Cross-service atomic commits | Possible - a single commit can update multiple services and their shared library together | Not possible - requires coordinated multi-repo releases |
| CI trigger scope | Requires path-based/dependency-graph-aware triggering to avoid rebuilding everything on every commit | Naturally scoped - each repo's pipeline only sees its own changes |
| Code ownership | Requires CODEOWNERS-style path-based review routing | Naturally scoped by repository boundary |
| Dependency versioning between services | Trivial (always in lockstep, same commit) | Requires explicit versioning/publishing of shared libraries |
| Repository size / clone/checkout cost | Grows without bound; may require sparse checkout or partial clone at scale | Stays small per repository |
| Best fit | Organizations wanting synchronized cross-service refactors, shared tooling, single CI/CD story | Independent teams/services with different release cadences, strong ownership boundaries, or microservices deployed and versioned fully independently |
Monorepo-specific practices:
# Path-based CI triggering (see ci_cd_patterns.md for the full pipeline)
git diff --name-only origin/main...HEAD | grep '^services/billing/' && run_billing_pipeline
Use sparse-checkout (git sparse-checkout) or partial clone (git clone --filter=blob:none) for very large monorepos to keep local checkouts fast.
Polyrepo-specific practices: Adopt a consistent tagging/versioning convention across repositories (see Best Practice #1) so that a shared library's consumers can track compatible versions, and use tools like Renovate/Dependabot to automate cross-repo dependency bumps.
Common Pitfalls & Anti-Patterns
Pitfall: Force-Pushing to a Shared Branch
Problem: git push --force on a branch others have pulled from rewrites history they depend on, causing confusing conflicts and potentially silently discarding their work on next pull.
Recommended approach: Use git push --force-with-lease at minimum (fails if the remote has commits you have not seen), and reserve force-pushing entirely for branches only you own (your own feature branch, never main/develop).
Pitfall: Giant, Unreviewable Pull Requests
Problem: A 3,000-line PR touching a dozen unrelated concerns is effectively unreviewable - reviewers either rubber-stamp it or spend disproportionate time that still misses issues.
Recommended approach: Keep PRs scoped to one logical change (Best Practice #2), and if larger work is unavoidable, land it as a stack of small, sequentially reviewable PRs behind a feature flag.
Pitfall: Committing Secrets or Large Binary Files
Problem: Secrets committed to git history persist indefinitely, even after later removal, and require history rewriting (git filter-repo) plus credential rotation to fully remediate. Large binaries bloat repository size for every future clone.
Recommended approach: Use .gitignore proactively, pre-commit secret-scanning hooks (gitleaks), and Git LFS for legitimately required large assets. See Secure Coding Practices for secrets management more broadly.
Pitfall: Merge Commits Full of Unrelated Trunk Changes
Problem: Merging trunk into a long-lived feature branch repeatedly, rather than rebasing or keeping the branch short-lived, produces a tangled history where the actual feature diff is buried in unrelated merge noise.
Recommended approach: Keep feature branches short-lived (Best Practice #3); when they must live longer, prefer periodically rebasing onto trunk rather than merging trunk in, so the eventual PR diff stays clean.
Testing Strategies
- Pre-commit hooks for fast feedback: Run linting, formatting checks, and secret scanning as pre-commit hooks so violations are caught before they even reach a PR, not just in CI.
- CI validation of commit message format: Enforce Conventional Commits format with a commit-lint step in the pipeline (see CI/CD Patterns) so changelog automation does not silently break on a malformed commit.
- Branch protection rules as policy-as-code: Require passing CI, at least one review approval, and (where applicable) signed commits before merge to
main- configure this as reviewable repository configuration, not tribal knowledge. - Test the actual merge result, not just the branch in isolation: CI should run against the merge commit (PR merged into target) rather than only the feature branch's tip, catching integration issues invisible from the branch alone.
Performance, Security & Scalability Considerations
- Repository size at scale: Large monorepos benefit from shallow clones (
--depth), partial clone (--filter=blob:none), and sparse-checkout in CI runners to avoid multi-gigabyte checkout times on every pipeline run. - Commit signing verification at scale: Enforce signature verification via branch protection rather than manual review - this is a policy the platform (GitHub/GitLab) enforces automatically once configured, at negligible ongoing cost.
- History rewriting is expensive and risky:
git filter-repoto purge a leaked secret from history requires coordinated force-push and re-clone by every collaborator - treat secret leaks as requiring rotation of the credential itself first (fastest mitigation), with history rewriting as a secondary, best-effort cleanup. - CI trigger cost in monorepos: Without path-based triggering, every commit rebuilds every service, which does not scale past a small number of services - see the monorepo comparison above.
Edge Cases & Advanced Usage
- Reverting a merge commit:
git revert -m 1 <merge-commit-sha>is required (rather than a plaingit revert) because a merge commit has multiple parents; specify which parent's history to preserve. - Bisecting across a rebase-heavy history vs. a merge-heavy history: Linear (rebased) history bisects trivially; heavily merged history may require
git bisect --first-parentto stay on the trunk's logical timeline rather than descending into merged branch commits. - Recovering a "lost" commit:
git reflogretains references to commits no longer reachable from any branch (e.g., after a bad rebase or reset) for a local repository's retention window - check it before assuming work is unrecoverable. - Cross-repository atomic changes in a polyrepo: When a change genuinely must span multiple repositories (e.g., a breaking shared-library change), use a versioned release of the shared library plus coordinated, sequenced PRs in dependent repositories rather than attempting a single atomic commit that does not exist across repository boundaries.
- Submodules vs. monorepo for shared code: Git submodules technically allow embedding one repository inside another but are widely considered high-friction (detached HEAD pitfalls, easy-to-forget submodule updates); prefer either a true monorepo or a published, versioned package for shared code.
When Not to Use / Alternatives
The guidance above assumes git as the version control system; the branching model and workflow choices should also be reconsidered for certain contexts.
| Dimension | Trunk-Based Development | GitFlow |
|---|---|---|
| Continuous deployment | Excellent fit | Poor fit - batching conflicts with continuous release |
| Regulated, audited release trains with formal sign-off | Requires bolt-on approval gates | Natural fit - release branches map directly to approval boundaries |
| Long-term support of multiple prior versions simultaneously | Awkward - requires separate long-lived support branches regardless | Natural fit - release/* branches map to supported versions |
Choose GitFlow-style long-lived release branches specifically when a product must support multiple prior major versions in production simultaneously (e.g., enterprise software with staggered customer upgrade cycles) - trunk-based development does not eliminate the need for support branches in that scenario, it only avoids using them for routine development.
Related Documentation
- CI/CD Patterns - how branching model choice drives pipeline and deployment design
- Docker Best Practices - image tagging conventions that pair with git tags/commit SHAs
- Dockerfile Best Practices -
.dockerignorealongside.gitignoreconventions - Autonomous, Least-Privilege, Portable Execution Environments - non-interactive requirements for scripted git operations in CI/agent contexts
- Secure Coding Practices - preventing secrets from entering commit history
- OWASP Top 10 - Software Supply Chain Failures category relevant to commit signing
- Microservices Architecture - polyrepo/monorepo trade-offs at the service-boundary level
- Integration Testing Strategies - testing merge commits and branch protection policy
Glossary
- Conventional Commits: A commit message specification (
type(scope): description) enabling automated changelog and semantic version generation. - Trunk-based development: A branching model where all developers integrate short-lived branches into a single trunk (
main) at least daily. - Rebase: Replaying a sequence of commits onto a new base commit, producing linear history; unsafe on already-shared/pushed branches.
- Fast-forward merge: A merge where the target branch pointer simply advances, with no merge commit created, because the source branch's history is a direct continuation.
- Reflog: A local, time-limited log of every position a branch reference has pointed to, usable to recover commits otherwise unreachable after a reset or bad rebase.