Skip to main content

Git Best Practices for .NET Teams: Branching & PRs

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 .csproj produce conflicts where "accept both" can yield duplicate PackageReference entries and a build that works locally but not in CI.
  • Long-lived solutions with many projects. A change in a shared Domain project 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:

  • main is always releasable and protected.
  • Every change starts as a branch off main and 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 main per unit of work makes git bisect and git revert trivial — 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 covers bin/, obj/, .vs/, *.user, and test results correctly.
  • Secrets in appsettings.json. Use dotnet user-secrets locally 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.json when you enable RestorePackagesWithLockFile, 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 Down target 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=auto plus explicit rules for *.sln and *.csproj.
  • Committing *.sln churn. Prefer .slnx (supported by .NET 9 SDK tooling and Visual Studio 2022 17.13+) or dotnet sln add over 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; keep release/* 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 format with logic changes.
  • Keep PRs under ~400 lines with a description written for the reviewer, and squash-merge so main stays 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...