tools/git/best_practices.md

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:

TypeMeaningTriggers version bump (semantic-release style)
featNew featureMinor
fixBug fixPatch
docsDocumentation onlyNone
refactorCode change with no behavior changeNone
perfPerformance improvementPatch
testAdding or correcting testsNone
choreTooling, build config, dependency bumpsNone
BREAKING CHANGE footerAny breaking API changeMajor

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).

DimensionTrunk-Based DevelopmentGitHub FlowGitFlow
Long-lived branchesNone - trunk only, feature branches live hours to ~1-2 daysShort-lived feature branches merged via PRmain, develop, release/, hotfix/ - several long-lived branches
Typical merge frequencyMultiple times per day per developerDaily to a few times per weekBatched per release cycle
Requires feature flagsYes, for incomplete workOftenRarely (branches hide incomplete work)
Release versioningContinuous, often tag-per-deployContinuous or on-demand tagExplicit, batched version tags per release branch
Conflict resolution burdenLow (small, frequent diffs)Low-mediumMedium-high (large accumulated diffs)
Best fitContinuous deployment, high-velocity teams, SaaSWeb apps/APIs with continuous delivery, small-to-mid teamsSoftware with formal release trains - embedded, installed desktop software, regulated versioned releases
Hotfix pathFix on trunk, deploy immediatelyFix via short-lived branch + PR, deploy immediatelyDedicated 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.

Dimensiongit rebasegit merge
History shapeLinear - replays commits onto a new basePreserves actual chronological/topological history, including a merge commit
Safe on shared/pushed branchesNo (rewrites commit hashes) - safe only on local, unpushed, or exclusively-owned branchesYes, always safe
BisectabilityExcellent - linear history is trivial to bisectCan be harder if merge commits interleave unrelated work
Preserves "what actually happened"No - history is rewritten as if developed seriallyYes - shows the real branch/merge topology
Typical useCleaning 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.

DimensionMonorepoPolyrepo
Cross-service atomic commitsPossible - a single commit can update multiple services and their shared library togetherNot possible - requires coordinated multi-repo releases
CI trigger scopeRequires path-based/dependency-graph-aware triggering to avoid rebuilding everything on every commitNaturally scoped - each repo's pipeline only sees its own changes
Code ownershipRequires CODEOWNERS-style path-based review routingNaturally scoped by repository boundary
Dependency versioning between servicesTrivial (always in lockstep, same commit)Requires explicit versioning/publishing of shared libraries
Repository size / clone/checkout costGrows without bound; may require sparse checkout or partial clone at scaleStays small per repository
Best fitOrganizations wanting synchronized cross-service refactors, shared tooling, single CI/CD storyIndependent 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-repo to 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 plain git 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-parent to stay on the trunk's logical timeline rather than descending into merged branch commits.
  • Recovering a "lost" commit: git reflog retains 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.

DimensionTrunk-Based DevelopmentGitFlow
Continuous deploymentExcellent fitPoor fit - batching conflicts with continuous release
Regulated, audited release trains with formal sign-offRequires bolt-on approval gatesNatural fit - release branches map directly to approval boundaries
Long-term support of multiple prior versions simultaneouslyAwkward - requires separate long-lived support branches regardlessNatural 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.

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.