
Learn Git best practices for .NET teams — branching strategies, clean commits, and pull request reviews that scale. Start shipping safer C# code today.
Most .NET teams don't lose time to hard algorithms. They lose it to a 4,000-line pull request nobody wants to review, a develop branch that hasn't been merged in three weeks, and a merge conflict in a .csproj file that silently drops a package reference. Adopting solid git best practices fixes more delivery problems than any refactor, because it changes how your team integrates work rather than how one developer writes it.
This guide covers the Git workflow decisions that matter specifically for C# and .NET teams: which branching strategy to pick, how to write commits that survive a production incident, how to keep pull requests reviewable, and the .NET-specific pitfalls (solution files, generated code, secrets in appsettings.json) that generic Git tutorials never mention.
Why Git Best Practices Matter More in .NET Codebases
.NET repositories have properties that punish sloppy Git hygiene:
- Generated and binary artifacts everywhere.
bin/,obj/,.vs/, EF Core migration bundles, and scaffolded clients bloat history permanently if committed once. - XML project files that merge badly. Two developers adding NuGet packages to the same
.csprojproduce conflicts where "accept both" can yield duplicatePackageReferenceentries and a build that works locally but not in CI. - Long-lived solutions with many projects. A change in a shared
Domainproject can break six consumers. Late integration means finding out late. - Compile-time coupling. Unlike dynamic languages, a broken signature stops the whole build. A stale branch is a guaranteed conflict, not a maybe.
The throughline: in .NET, integration cost grows superlinearly with branch age. Almost every practice below exists to shorten the distance between "I wrote code" and "it's merged into main."
Choose a Branching Strategy: Trunk-Based Beats Git Flow for Most Teams
Git Flow (with main, develop, release/*, hotfix/*, and feature/*) was designed in 2010 for versioned desktop software shipped a few times a year. If you ship an ASP.NET Core API to Azure multiple times a week, it's overhead: you maintain two permanent branches, cherry-pick hotfixes twice, and resolve the same conflict in two places.
For most modern .NET teams, use trunk-based development with short-lived branches:
mainis always releasable and protected.- Every change starts as a branch off
mainand lives one to two days, not two weeks. - Branches merge back via pull request with CI gates.
- Releases are tags on
main, not branches.
// Typical daily loop for a .NET developer on trunk-based development
// (shell commands shown as comments for reference)
// git switch main
// git pull --ff-only
// git switch -c feat/1423-invoice-pdf-export
// ... work, commit in small logical chunks ...
// git fetch origin
// git rebase origin/main // keep history linear, resolve conflicts once
// git push --force-with-lease // safe force: fails if someone else pushed
// gh pr create --fill
Keep release/* branches only if you genuinely support multiple versions in production simultaneously — for example, an on-premises product or a public NuGet library where customers stay on v7 while you build v8. That's a real constraint; a SaaS API isn't.
Branch Naming That Tooling Can Parse
Pick a convention with a type prefix and an issue ID so Azure DevOps or GitHub can auto-link work items:
// feat/1423-invoice-pdf-export
// fix/1502-null-ref-in-order-validator
// chore/1510-bump-efcore-9
// refactor/1533-extract-pricing-service
Avoid rajni-work or test2. Six months later, branch archaeology is real work.
Feature Flags Are What Make Short Branches Possible
The standard objection to trunk-based development is: "my feature takes three weeks, I can't merge daily." You can — merge the incomplete but inert code behind a flag. .NET has first-class support via Microsoft.FeatureManagement:
public sealed class InvoiceController : ControllerBase
{
private readonly IFeatureManager _features;
private readonly IInvoiceService _invoices;
public InvoiceController(IFeatureManager features, IInvoiceService invoices)
{
_features = features;
_invoices = invoices;
}
[HttpGet("{id:guid}/export")]
public async Task<IActionResult> Export(Guid id, CancellationToken ct)
{
// Merged to main on day 1; enabled in production on day 21.
if (!await _features.IsEnabledAsync("PdfInvoiceExport"))
return NotFound();
var pdf = await _invoices.RenderPdfAsync(id, ct);
return File(pdf, "application/pdf", $"invoice-{id}.pdf");
}
}
Now "merge frequently" and "release when ready" are independent decisions. That single shift eliminates most long-lived-branch pain.
Git Best Practices for Commits in a C# Repository
A commit is a message to whoever debugs your code at 2 a.m. — often you. Two rules carry most of the value: one logical change per commit, and explain why in the body.
Use Conventional Commits
Conventional Commits gives you machine-readable history, which means automated changelogs and semantic versioning for your NuGet packages via tools like MinVer or Nerdbank.GitVersioning:
/*
fix(orders): guard against null shipping address in validator
OrderValidator dereferenced Order.ShippingAddress without a null check,
throwing NullReferenceException for digital-only orders created through
the new checkout API. Digital orders legitimately have no address.
Added an early return plus a regression test covering the digital path.
Fixes #1502
*/
Structure: type(scope): subject, imperative mood, subject under ~50 characters, blank line, then the body wrapped at 72. Types: feat, fix, docs, refactor, test, perf, build, ci, chore. A ! or BREAKING CHANGE: footer marks a major version bump.
Bad commits to stop writing today: fix, wip, updates, final fix for real this time, and the worst offender — Merge branch 'main' into feature/x repeated eleven times.
Separate Formatting From Logic
This is a .NET-specific trap. Running dotnet format or letting an IDE reorganize usings in the same commit as a behaviour change makes the diff unreviewable. Commit the reformat alone, then the logic:
// Commit 1: style: apply dotnet format to Billing project (800 lines, 0 risk)
// Commit 2: feat(billing): apply regional tax rounding rules (40 lines, all risk)
Reviewers can skim commit 1 and read commit 2 carefully. Better still, enforce formatting in CI so it never drifts:
// In Directory.Build.props — fail the build on style violations
/*
<Project>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
</PropertyGroup>
</Project>
*/
Clean Up Locally Before You Push
Rewrite your own unpushed history freely; never rewrite shared history. Interactive rebase turns six messy commits into three meaningful ones:
// git rebase -i origin/main // squash/reword/reorder your own commits
// git commit --fixup <sha> // mark a follow-up fix
// git rebase -i --autosquash origin/main // folds fixups automatically
Pull Request Best Practices That Keep Reviews Fast
Research on code review consistently finds defect-detection quality collapses past roughly 400 changed lines, and attention drops sharply after about 60 minutes. Your process should respect that.
Keep PRs Under ~400 Lines
If a PR exceeds that, split it: interfaces and DTOs first, then implementation, then wiring and integration tests. Three 200-line PRs merged over two days beat one 900-line PR that sits for a week, accumulates conflicts, and gets a "LGTM" nobody means.
Write the Description for the Reviewer, Not the Ticket
Commit a .github/pull_request_template.md so structure is automatic:
/*
## What
Adds PDF export for invoices behind the PdfInvoiceExport feature flag.
## Why
Finance currently screenshots the invoice page (see #1423). Flagged off
in all environments until QA signs off.
## How
- New IInvoiceRenderer abstraction + QuestPdf implementation
- Registered as scoped in Program.cs
- Endpoint returns 404 when the flag is off
## Testing
- Unit tests for renderer (golden-file comparison)
- Integration test asserting 404 with flag off, 200 with flag on
- Manually verified against 3 legacy invoices with multi-currency lines
## Risk
Low — new code path, flag-gated, no schema change.
*/
Protect main With Real Gates
Branch protection on main should require: at least one approval, all conversations resolved, a green CI run (dotnet build, dotnet test, format check, and a vulnerability scan via dotnet list package --vulnerable --include-transitive), a linear history, and stale approvals dismissed on new commits. If a rule isn't enforced by tooling, it isn't a rule — it's a hope.
Review Norms Worth Writing Down
- Respond within one business day. Review latency, not review depth, is usually the bottleneck.
- Label comment severity:
blocking:,suggestion:,nit:,question:. This single convention removes most review friction. - Critique code, never the author. "This allocates on every request" not "you don't understand allocation."
- Squash-merge feature branches. One coherent commit on
mainper unit of work makesgit bisectandgit reverttrivial — which is exactly what you want during an incident.
.NET-Specific Pitfalls to Avoid
- Missing or hand-rolled .gitignore. Always start with
dotnet new gitignore. It coversbin/,obj/,.vs/,*.user, and test results correctly. - Secrets in appsettings.json. Use
dotnet user-secretslocally and Azure Key Vault or environment variables in deployed environments. Enable GitHub secret scanning and push protection. Remember: reverting a commit does not remove a leaked key from history — rotate it immediately. - Uncommitted lock files. Commit
packages.lock.jsonwhen you enableRestorePackagesWithLockFile, so CI restores exactly what you tested. - Merging EF Core migrations blindly. Two branches each adding a migration produce a broken chain because the second one's
Downtarget is wrong. Rebase, delete your migration, and regenerate it against the updated model. - No .gitattributes. Without it, mixed Windows/macOS/Linux teams get phantom whole-file diffs. Add
* text=autoplus explicit rules for*.slnand*.csproj. - Committing
*.slnchurn. Prefer.slnx(supported by .NET 9 SDK tooling and Visual Studio 2022 17.13+) ordotnet sln addover letting the IDE rewrite GUID blocks on every touch.
Key Takeaways
The git best practices that actually move the needle for .NET teams are about shortening feedback loops, not about memorizing Git commands:
- Branch short, merge often. Trunk-based development with one-to-two-day branches off a protected
main; keeprelease/*only if you truly support multiple versions. - Feature flags decouple merge from release, which is what makes short branches realistic for multi-week features.
- One logical change per commit, Conventional Commits format, and the why in the body. Never mix
dotnet formatwith logic changes. - Keep PRs under ~400 lines with a description written for the reviewer, and squash-merge so
mainstays bisectable. - Automate every rule — branch protection, CI build and tests, format checks, vulnerability scans, secret scanning. Conventions that depend on discipline decay.
- Handle .NET specifics deliberately: generated
.gitignore,.gitattributes, committed lock files, no secrets in config, and regenerate EF Core migrations after a rebase rather than merging them.
Pick one change to start with. If your team's branches routinely live longer than a week, fix that first — shortening branch lifetime improves commit quality, PR size, and review speed all at once, because those problems were symptoms of it all along.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment