Every Salesforce team eventually adopts Git. Far fewer adopt a Git workflow that matches how Salesforce environments actually work. The result is a repo that technically has version history but doesn't reflect what's really deployed where — which defeats most of the point of using Git in the first place.

This is the workflow that holds up in practice: one branch per environment, short-lived feature branches, scratch orgs created from those branches, pull requests as the only path to merge, and a plan for the metadata conflicts that Salesforce is unusually good at producing.

Branch Per Environment, Not Just Per Feature

The mistake most teams make is treating Git the way a pure software team would — main plus feature branches, nothing else. Salesforce doesn't work that way because you don't have one production environment and a laptop. You have a chain: production, a UAT or staging sandbox, maybe a partial or full sandbox for integration testing, and then developer sandboxes or scratch orgs underneath all of it.

Your branch structure should mirror that chain:

A feature branch merges into uat first via pull request. Once it's validated there, that same change gets promoted into main and deployed to production. The branch structure isn't decoration — it's a literal map of your deployment path, and anyone new to the team can read it and understand exactly how code moves.

The single biggest predictor of a clean Salesforce repo is whether the branch names describe environments people can point to, not just abstract Git conventions borrowed from a blog post about web apps.

.gitignore for a Salesforce DX Project

A Salesforce DX project generates a fair amount of local noise that has no business in version control. A working .gitignore looks like this:

.sfdx/
.sf/
.localdevserver
.vscode/
*.log
node_modules/
.forceignore.bak

Two things people mix up constantly. force-app is your actual metadata source — it gets committed, always, in full. It is not something you ignore. .forceignore is a completely different mechanism: it's a file the Salesforce CLI reads to decide what to include or exclude during deploy and retrieve operations. It controls CLI behavior, not Git tracking. Confusing the two is one of the most common early mistakes — people either commit .sfdx/ full of local auth tokens, or they add force-app to .gitignore by accident while copying a template and wonder why their repo is empty.

Connecting a Scratch Org to a Branch

There's no built-in binding between a Git branch and a scratch org — Salesforce doesn't track that relationship for you. The convention that works is procedural discipline, not tooling:

  1. Check out the feature branch first: git checkout -b feature/loyalty-flow
  2. Create the scratch org from that checked-out state: sf org create scratch -f config/project-scratch-def.json -a loyalty-flow --set-default
  3. Every sf project deploy start you run for the rest of that feature pushes from this same branch's working directory into this same org

Name the scratch org alias after the branch — loyalty-flow, not scratch1 — so when you run sf org list against a handful of active orgs, it's obvious which org belongs to which piece of work. This matters more than it sounds like it should once you have three or four features in flight at once.

Pull Request Workflow

Direct pushes to main or uat should not be possible — that's a branch protection setting in GitHub, not a policy people are expected to remember. Every change goes through a pull request:

The two-PR pattern — feature into uat, then uat into main — feels like overhead the first few times. It stops feeling that way the first time it catches a change that worked fine in isolation but broke something when combined with three other features that had already landed in UAT.

Resolving package.xml and Metadata Merge Conflicts

This is the part that scares people away from a real branching strategy, and it's more mechanical than it looks once you've done it a few times.

package.xml conflicts

These are almost always duplicate or missing <members> entries — two branches both added a new custom field, and Git can't automatically figure out that both additions should survive. Resolve by hand: keep every member either branch added, remove exact duplicates, and re-sort alphabetically within each <types> block. It's tedious, not difficult.

Flow and Profile conflicts

These are the genuinely hard ones, because Flow XML and Profile XML are large, deeply nested, and not designed to be human-merged. Two developers editing the same Flow in parallel is the single most common source of a merge conflict nobody wants to resolve by hand. When the conflict is small — one new node added on each side — resolve it directly in the XML. When it's tangled — both sides restructured logic — it's usually faster to abandon the line-by-line merge, retrieve the Flow fresh from an org where both changes have been validated together, and let that retrieved version become the new source of truth.

The prevention that actually works

Merge conflicts in Flows are a symptom of two people touching the same automation without knowing it. The fix isn't a smarter merge tool — it's a five-minute Slack message before starting work on shared automation. Most teams learn this the expensive way.

GitHub Actions for Salesforce CI

Once the branch structure and PR workflow are in place, GitHub Actions is what makes "passed CI" mean something real. A minimal workflow, triggered on every pull request:

name: PR Validation
on:
  pull_request:
    branches: [uat, main]

jobs:
  scratch-org-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install --global @salesforce/cli
      - run: sf org login sfdx-url -f ./devhub-auth.txt -a DevHub -d
      - run: sf org create scratch -f config/project-scratch-def.json -a ci-org --set-default --duration-days 1
      - run: sf project deploy start --source-dir force-app
      - run: sf apex run test --test-level RunLocalTests --result-format human --wait 20
      - run: sf org delete scratch -o ci-org --no-prompt

Every pull request gets its own disposable scratch org, built from exactly the code in that PR, tested, then deleted regardless of outcome. This is the payoff of everything above — the branching model gives CI something meaningful to validate against, and CI gives the branching model teeth. Without both pieces together, you either have clean branches nobody tests, or tests running against an org that doesn't reflect the actual PR.

Putting It Together

None of this is exotic. It's a small number of disciplined habits: branches that mirror real environments, a .gitignore that keeps local noise out, scratch orgs created deliberately from the branch they belong to, pull requests as the only path to merge, and a plan — not a hope — for the metadata conflicts that will happen. Teams that adopt this stop treating Git as paperwork bolted onto Salesforce development and start treating it as the actual source of truth for what's deployed where. That shift is worth more than any individual command in this article.

Frequently Asked Questions

What branching strategy should Salesforce teams use?

Branch per environment, mirrored by feature branches. A common pattern is main tracking production, a uat or staging branch tracking your UAT sandbox, and short-lived feature branches cut from main for each piece of work, merged back through a pull request into the environment branch that matches where the change is headed next.

What should be in a .gitignore file for a Salesforce DX project?

At minimum: .sfdx/ (local CLI state and auth tokens), .sf/ (newer CLI config), .localdevserver, and any local config or log files your tooling generates. force-app is your actual source and should be committed, not ignored. .forceignore is a different file entirely — it controls what the CLI pushes and retrieves, not what Git tracks.

How do you connect a scratch org to a Git branch?

There is no literal binding between an org and a branch, but the convention that works is: check out the feature branch first, then create the scratch org from that branch's state. Every deploy start you run afterward should push from that same branch's working directory, so the org and the branch stay in sync for the life of the feature.

How do you resolve package.xml or metadata merge conflicts?

For package.xml, conflicts are almost always duplicate or missing <members> entries — resolve by hand, keeping every member either branch added, and let the file stay alphabetically sorted. For metadata files like Flows or Profiles, resolve by understanding both branches' intent rather than blindly taking one side; when a Flow conflict is too tangled to read, it's often faster to retrieve the metadata fresh from an org where both changes are validated and rebuild the merge from there.