Every student learns the same three Git commands: git add, git commit, git push. You use them on personal projects, push directly to main, and everything works fine. Then you join a team — an internship, an open-source project, a group capstone — and suddenly Git feels like a completely different tool. Someone mentions "rebasing onto main," another person asks you to "squash your commits before merging," and your pull request gets rejected because you pushed to a protected branch.

The gap between solo Git and team Git is one of the biggest surprises new developers face on the job. This article covers everything you need to close that gap: branch strategies that real companies use, how to write pull requests that get approved quickly, conventional commit formats that enable automation, CI/CD pipelines with GitHub Actions, and how to handle merge conflicts without panicking.

1. Solo Git vs Team Git

When you work alone, Git is essentially a save button with history. You commit when you feel like it, your commit messages say things like "fixed stuff" or "asdfg," and you push directly to main because there is no one to conflict with. This workflow is fine for personal projects. It will get you fired on a team.

Team Git introduces constraints that feel annoying at first but exist for very good reasons. Here is what changes:

You Cannot Push to Main

On almost every professional team, the main branch (or master) is protected. This means you literally cannot push commits to it directly. Instead, you create a branch, make your changes there, open a pull request, get at least one approval from a teammate, wait for automated tests to pass, and then merge. This sounds slow compared to git push origin main, but it prevents the single most common disaster in team development: someone pushing broken code that affects everyone.

# Solo workflow (what you know)
git add .
git commit -m "added login"
git push origin main

# Team workflow (what jobs require)
git checkout -b feature/add-login-page
git add src/pages/Login.tsx src/api/auth.ts
git commit -m "feat: add login page with Google OAuth"
git push origin feature/add-login-page
# Then: open pull request on GitHub
# Then: wait for code review + CI to pass
# Then: merge via GitHub UI

Every Change Gets Reviewed

Code review is not optional on professional teams. Before your code reaches the main branch, at least one other developer reads it line by line. They check for bugs, style inconsistencies, security issues, and whether your approach makes sense. This feels like judgment at first, but it is actually the single best way to learn. Reviewers will catch things you did not think of, suggest better patterns, and help you write cleaner code faster than any tutorial could.

Commit Messages Matter

On a solo project, nobody reads your commit history. On a team, commit messages are documentation. When someone runs git log six months from now trying to figure out why a feature works a certain way, your commit message is all they have. "fixed stuff" tells them nothing. "fix: resolve OAuth redirect loop when callback URL contains query params" tells them exactly what was broken and why the change was made.

You Work on Branches, Not on Main

Every feature, bug fix, or change gets its own branch. This isolation means your half-finished work does not affect anyone else. You can experiment, break things, and rewrite code freely on your branch. When it is ready, you merge it. If it is never ready, you delete the branch and no harm is done.

Branch naming follows conventions so that anyone on the team can look at a branch name and understand what it contains:

feature/add-user-auth # New feature
feature/shopping-cart # New feature
fix/login-redirect-loop # Bug fix
fix/null-pointer-dashboard # Bug fix
chore/update-dependencies # Maintenance
chore/upgrade-node-18 # Maintenance
docs/api-endpoint-guide # Documentation
refactor/extract-auth-service # Code improvement

The prefix tells you the type of change. The rest describes what the change does. Use hyphens, not spaces or underscores. Keep it short but descriptive. Your team should agree on these prefixes and use them consistently.

The mindset shift: Solo Git is about saving your work. Team Git is about communicating your work. Every branch name, commit message, and pull request description is a message to your teammates — including future teammates who have not joined the team yet.

2. Branch Strategies Compared

Not all teams manage branches the same way. There are three dominant strategies, each designed for different team sizes and release cycles. Picking the wrong one creates unnecessary friction. Picking the right one makes collaboration feel natural.

Trunk-Based Development

In trunk-based development, everyone commits directly to the main branch (the "trunk") or uses very short-lived branches that last no more than a day or two. There are no long-running feature branches. If a feature is not ready for users, you hide it behind a feature flag — a configuration switch that lets you deploy code without exposing it.

This strategy requires strong CI/CD. Every commit to main triggers automated tests, and if the tests pass, the code can be deployed immediately. There is no "release day." Releases happen continuously, sometimes dozens of times per day.

Google, Facebook, and most large tech companies use trunk-based development. It works because it forces small, frequent commits. Merge conflicts are rare because branches do not exist long enough to diverge significantly. The tradeoff is that you need excellent test coverage and feature flags, which adds complexity.

Best for: Small, experienced teams. Continuous deployment. Web applications where you can deploy anytime.

GitHub Flow

GitHub Flow is the simplest branching strategy that supports code review. You have one permanent branch: main. For every change, you create a feature branch off main, do your work, open a pull request, get it reviewed, and merge it back into main. That is the entire workflow.

There are no develop branches, no release branches, no hotfix branches. Just main and short-lived feature branches. When something is merged to main, it is considered deployable. Most teams set up automatic deployment from main so that merging a pull request immediately ships the change to production.

This is what most web development teams use because it is simple enough to learn in five minutes but structured enough to prevent chaos. If you are starting a team project and do not know which strategy to pick, pick GitHub Flow.

Best for: Most web applications. SaaS products. Teams of any size. Projects with continuous deployment.

Git Flow

Git Flow uses five types of branches: main (production code), develop (integration branch), feature/* (new features), release/* (preparing a release), and hotfix/* (emergency production fixes). Features branch off develop and merge back into develop. When you are ready for a release, you create a release branch from develop, do final testing and bug fixes on it, then merge it into both main and develop. Hotfixes branch off main and merge back into both main and develop.

This strategy was invented in 2010 and was the dominant workflow for years. It is well-suited for software that ships in versions — mobile apps, desktop software, APIs with version numbers. But for web applications that deploy continuously, Git Flow adds unnecessary ceremony. You do not need a release branch if every merge to main triggers a deployment.

Best for: Versioned software. Mobile apps. Libraries and frameworks. Projects with scheduled releases.

Comparison Table

Aspect Trunk-Based GitHub Flow Git Flow
Complexity Low Low High
Branches main only main + feature main + develop + feature + release + hotfix
PR required Optional Always Always
Release style Continuous Continuous Scheduled
Feature flags Required Optional Not needed
Merge conflicts Rare Occasional Frequent
Learning curve Medium Easy Steep
Used by Google, Meta Most startups, GitHub Mobile teams, open-source libraries

Practical advice: If you are building a web app or SaaS product, use GitHub Flow. It is simple, well-documented, and the tooling (GitHub, GitLab, Bitbucket) is built around it. Only adopt Git Flow if you ship versioned software. Only adopt trunk-based development if your team has strong test coverage and CI/CD infrastructure already in place.

3. The Pull Request Workflow

Pull requests (PRs) are where team collaboration actually happens. A PR is not just a request to merge code — it is a conversation about the change. Done well, PRs improve code quality, spread knowledge across the team, and catch bugs before they reach production. Done poorly, they become a bottleneck that frustrates everyone.

Creating a Good Pull Request

Keep PRs small. This is the single most impactful thing you can do. Research from Google's engineering practices and SmartBear's study of code review effectiveness consistently shows that reviewers catch more bugs in smaller PRs. A PR with 400+ lines of changes gets a cursory glance. A PR with 100–200 lines gets a thorough review. If your feature requires more code than that, break it into multiple PRs that can be reviewed and merged independently.

Write a descriptive title. The PR title should summarize the change in one line, similar to a commit message. "Fix bug" is useless. "Fix OAuth redirect loop when callback URL contains query parameters" tells the reviewer exactly what to expect.

Link to the issue. If your PR addresses a GitHub issue, reference it in the description with "Closes #42" or "Fixes #42." This automatically closes the issue when the PR is merged and creates a traceable link between the problem and the solution.

Include screenshots for UI changes. If your PR changes anything visual, include before and after screenshots. Reviewers should not have to check out your branch and run the app just to see what a button looks like. Many teams use screen recordings for interactive changes.

Self-review before requesting review. Before you tag a teammate, review your own PR. Read every line of the diff on GitHub. You will catch typos, leftover debug logs, and commented-out code that you forgot to remove. This shows respect for your reviewer's time and catches embarrassing issues before someone else finds them.

A PR Template

Most teams use a PR template that auto-populates when you open a new PR. Here is a solid starting template:

## What does this PR do?
<Brief description of the change and why it is needed>

## Related Issue
Closes #<issue number>

## How to Test
1. Check out this branch
2. Run `npm install`
3. Navigate to /login
4. Click "Sign in with Google"
5. Verify redirect works without loop

## Screenshots
<Before and after, if applicable>

## Checklist
- [ ] Tests pass locally
- [ ] No console errors or warnings
- [ ] Self-reviewed the diff
- [ ] Updated documentation if needed

Save this as .github/pull_request_template.md in your repository and GitHub will use it automatically for every new PR.

Code Review Etiquette

Code review is a skill that most schools do not teach, and getting it wrong creates team friction fast. Here are the ground rules that effective teams follow:

Be specific. "This is wrong" is not helpful. "This will throw a null pointer if user.profile is undefined — consider adding optional chaining: user?.profile?.name" is helpful. Always explain what the problem is and suggest an alternative.

Distinguish between blocking and non-blocking feedback. A security vulnerability is blocking — the PR cannot merge until it is fixed. A variable name you would have chosen differently is non-blocking — mention it as a suggestion, but do not hold up the PR over it. Many teams use prefixes like "nit:" for non-blocking comments and "blocker:" for issues that must be addressed.

Praise good code. If you see a clever solution, clean abstraction, or well-written test, say so. Code review should not be exclusively negative. Positive feedback reinforces good habits and makes the review process feel collaborative rather than adversarial.

Do not nitpick style. If you find yourself commenting on indentation, bracket placement, or import order, your team needs a linter and a formatter, not more review comments. Set up ESLint and Prettier (for JavaScript) or Black and Ruff (for Python) to enforce style automatically. This eliminates an entire category of review friction.

Review within 24 hours. Stale PRs are one of the biggest productivity killers in software development. If someone opens a PR, they are blocked until it is reviewed. A PR that sits for three days means three days of context switching, three days of potential merge conflicts building up, and a frustrated teammate. Most high-performing teams have an informal rule: review within one business day.

Common mistake: New developers often take review comments personally. They are not personal. A reviewer pointing out that your error handling is incomplete is helping you write better code, not attacking your ability. The best engineers get the most review comments because they ship the most code. Treat every comment as a learning opportunity.

4. Conventional Commits

Conventional Commits is a specification for writing standardized commit messages. It looks like this:

<type>: <description>

# Examples:
feat: add user registration with email verification
fix: resolve memory leak in WebSocket connection handler
docs: add API authentication guide to README
chore: upgrade Express from 4.18 to 4.21
refactor: extract payment logic into PaymentService class
test: add integration tests for checkout flow
style: format auth module with Prettier
ci: add Node 20 to GitHub Actions test matrix
perf: cache database queries for product listing page

The Types Explained

  • feat: A new feature visible to users. Adding a search bar, implementing dark mode, creating an API endpoint.
  • fix: A bug fix. Something was broken and now it works correctly.
  • docs: Documentation only. README updates, code comments, API docs.
  • chore: Maintenance tasks that do not change application behavior. Updating dependencies, configuring tools, cleaning up files.
  • refactor: Code restructuring that does not change behavior. Renaming variables, extracting functions, reorganizing modules.
  • test: Adding or modifying tests. No production code changes.
  • style: Code formatting changes. White space, semicolons, bracket placement. No logic changes.
  • ci: Changes to CI/CD configuration. GitHub Actions workflows, Docker files, deployment scripts.
  • perf: Performance improvements. Caching, query optimization, lazy loading.

Why This Matters

Readable history. When you run git log --oneline, a conventional commit history is instantly scannable. You can see at a glance what each commit did without reading the code.

a1b2c3d feat: add shopping cart with quantity controls
d4e5f6g fix: prevent duplicate items in cart
h7i8j9k test: add unit tests for cart total calculation
l0m1n2o chore: update Stripe SDK to v14
p3q4r5s feat: add checkout page with payment form
t6u7v8w fix: handle declined card error gracefully
x9y0z1a docs: add payment integration guide

Auto-generated changelogs. Tools like standard-version and semantic-release can read your conventional commit history and automatically generate a changelog. Every feat: commit becomes a "Features" entry. Every fix: commit becomes a "Bug Fixes" entry. No manual changelog writing required.

Semantic versioning automation. If your project uses semantic versioning (major.minor.patch), conventional commits can automate version bumps. A feat: commit bumps the minor version (1.2.0 → 1.3.0). A fix: commit bumps the patch version (1.3.0 → 1.3.1). A commit with a BREAKING CHANGE footer bumps the major version (1.3.1 → 2.0.0). This removes human error from versioning.

Scope (Optional but Useful)

You can add a scope in parentheses to indicate which part of the codebase was affected:

feat(auth): add Google OAuth login
fix(cart): prevent negative quantity values
refactor(api): extract middleware into separate module
test(payment): add Stripe webhook integration tests

Scopes make large repositories much easier to navigate. When you run git log --oneline --grep="auth", you instantly find every commit related to authentication.

Getting started: You do not need any tools to start using conventional commits. Just follow the format in your commit messages. When you are ready for automation, add commitlint to your project to enforce the format and semantic-release to automate versioning and changelogs.

5. CI/CD with GitHub Actions

Continuous Integration (CI) means automatically running tests and checks every time someone pushes code. Continuous Deployment (CD) means automatically deploying code to production when it passes those checks. Together, CI/CD is the backbone of modern software delivery. GitHub Actions is the most popular way to set this up, and it is free for public repositories.

How It Works

You create a YAML file in your repository at .github/workflows/ci.yml. This file describes when to run, what to run, and in what environment. GitHub reads this file and executes the steps on their servers every time the trigger conditions are met.

Here is a practical workflow that covers the most common team needs: run tests on every pull request, lint the code, and deploy to production when code is merged to main.

name: CI/CD Pipeline

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm test

  lint:
    name: Lint Code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  deploy:
    name: Deploy to Production
    needs: [test, lint]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Deploy
        run: npx vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}

What This Workflow Does

  • Trigger: Runs on every pull request targeting main and every push to main.
  • test job: Checks out the code, installs Node.js 20, installs dependencies with npm ci (faster and more reliable than npm install in CI), and runs the test suite.
  • lint job: Same setup, but runs the linter instead of tests. Runs in parallel with the test job to save time.
  • deploy job: Only runs if both test and lint pass (needs: [test, lint]), and only on pushes to main (not on PRs). Builds the project and deploys to Vercel using a secret token stored in your repository settings.

Protected Branch Rules

CI is only useful if it is enforced. Without branch protection, someone can still push directly to main and skip the entire pipeline. Here is how to set up branch protection on GitHub:

Go to your repository → Settings → Branches → Add rule for main:

  • Require a pull request before merging: No direct pushes to main. All changes go through PRs.
  • Require approvals (1 or 2): At least one teammate must approve the PR.
  • Require status checks to pass: Select your CI jobs (test, lint). The PR cannot be merged until they are green.
  • Do not allow bypassing the above settings: Even repository admins must follow the rules.
  • Do not allow force pushes: Prevents destructive history rewriting on main.
# To verify your branch protection is working, try pushing directly to main:
git push origin main

# You should see:
# remote: error: GH006: Protected branch update failed for refs/heads/main
# remote: error: Required status check "test" is expected.
# To github.com:yourname/yourrepo.git
# ! [remote rejected] main -> main (protected branch hook declined)

# This error means it is working correctly.

Storing Secrets

Never hardcode API keys, tokens, or passwords in your workflow files. GitHub Actions provides encrypted secrets. Go to repository Settings → Secrets and variables → Actions → New repository secret. Reference them in your workflow as ${{ secrets.YOUR_SECRET_NAME }}. They are masked in logs and never exposed in the workflow output.

Start simple: You do not need a complex pipeline on day one. Start with just running tests on PRs. Once that works reliably, add linting. Then add deployment. Each step builds confidence that your main branch is always in a working state.

6. Handling Merge Conflicts

Merge conflicts happen when two people edit the same lines of the same file on different branches. Git cannot automatically determine which version to keep, so it asks you to resolve the conflict manually. This is the moment where most students panic. Do not panic. Conflicts are normal, expected, and straightforward to resolve once you understand the format.

Understanding the Conflict Markers

When you run git merge main or git pull origin main and there is a conflict, Git modifies the affected files with conflict markers. Here is what they look like:

function getGreeting(user) {
<<<<<<< HEAD
  return `Welcome back, ${user.displayName}!`;
=======
  return `Hello, ${user.firstName} ${user.lastName}!`;
>>>>>>> main
}

Here is what each part means:

  • <<<<<<< HEAD — Start of your changes (the branch you are on).
  • ======= — Divider between the two versions.
  • >>>>>>> main — End of the incoming changes (the branch you are merging in).

To resolve, you delete all three marker lines and keep the version you want. Sometimes you keep your version, sometimes you keep theirs, and sometimes you combine both. In this example, if the team decided to use displayName, the resolved code would be:

function getGreeting(user) {
  return `Welcome back, ${user.displayName}!`;
}

Step-by-Step Conflict Resolution

Here is the complete process for resolving a merge conflict:

# Step 1: Update your branch with the latest from main
git checkout feature/your-branch
git fetch origin
git merge origin/main

# Step 2: Git tells you which files have conflicts
# Auto-merging src/utils/greeting.js
# CONFLICT (content): Merge conflict in src/utils/greeting.js
# Automatic merge failed; fix conflicts and then commit the result.

# Step 3: Open the conflicted file and resolve the markers
# In VS Code, you will see inline buttons: "Accept Current",
# "Accept Incoming", "Accept Both", or edit manually

# Step 4: After resolving ALL conflicts, stage the fixed files
git add src/utils/greeting.js

# Step 5: Complete the merge
git commit -m "merge: resolve conflict in greeting utility"

# Step 6: ALWAYS test after resolving
npm test

Always test after resolving conflicts. The most dangerous merge conflicts are the ones that resolve without syntax errors but introduce logic bugs. Both versions of the code might be valid individually, but the combination might not work. Run your tests. Run the application. Verify that the feature you were working on still works and that the feature from main also still works.

Using VS Code's Merge Editor

VS Code has a built-in merge editor that makes conflict resolution much easier than editing marker text manually. When you open a file with conflicts, VS Code highlights the conflicting sections and shows buttons above each conflict: "Accept Current Change," "Accept Incoming Change," "Accept Both Changes," and "Compare Changes." For complex conflicts, click "Resolve in Merge Editor" to see a three-way diff: your version on the left, the incoming version on the right, and the resolved result at the bottom. This visual approach makes it much harder to accidentally delete code.

Merge vs Rebase vs Squash

When you bring changes from main into your feature branch, or when you merge your feature branch into main, you have three options. Each produces a different Git history.

Merge commit (git merge): Creates a new commit that combines both branches. Preserves the complete history of both branches. The history is accurate but can look messy with many merge commits. Best for: merging release branches, preserving a detailed record of how code evolved.

Rebase (git rebase main): Replays your branch's commits on top of main, as if you had started your branch from the current tip of main. Creates a linear history with no merge commits. The history is clean but technically rewritten — the commit hashes change. Best for: keeping your feature branch up to date with main before opening a PR.

Squash merge: Combines all of your branch's commits into a single commit on main. The feature branch's individual commits disappear from main's history. The history is very clean — one commit per feature. Best for: merging feature branches where the individual commits are not meaningful (like "WIP", "fix typo", "actually fix typo").

# Merge: preserves all history
git checkout main
git merge feature/add-auth
# Result: main has a merge commit + all individual commits

# Rebase: linear history
git checkout feature/add-auth
git rebase main
# Result: your commits appear after main's latest commit

# Squash merge: one clean commit
# (Usually done via GitHub PR settings: "Squash and merge" button)
# Result: main gets one commit with all your changes combined

Recommended approach: Use rebase to keep your feature branch up to date with main while you are working on it. Use squash merge when merging your feature branch into main via a PR. This gives you a clean, linear history on main where each commit represents one complete feature or fix. Use regular merge only for long-lived branches like release branches where preserving the full history matters.

Interactive Rebase: Cleaning Up Before a PR

Before you open a pull request, you can clean up your commit history using interactive rebase. This lets you combine, rename, reorder, or delete commits.

# Clean up the last 4 commits on your branch
git rebase -i HEAD~4

# Your editor opens with something like:
# pick a1b2c3d feat: add login form
# pick d4e5f6g fix typo in login form
# pick h7i8j9k WIP: working on validation
# pick l0m1n2o feat: add form validation

# Change it to:
# pick a1b2c3d feat: add login form
# fixup d4e5f6g fix typo in login form
# fixup h7i8j9k WIP: working on validation
# squash l0m1n2o feat: add form validation

# Commands:
# pick = keep the commit as is
# squash = combine with previous commit, edit the message
# fixup = combine with previous commit, discard the message
# reword = keep the commit but change the message
# drop = delete the commit entirely

This turns four messy commits into one or two clean commits that tell a clear story. Your reviewer sees a focused, well-described change instead of your stream of consciousness.

git stash: Saving Work in Progress

Sometimes you are in the middle of work on your feature branch and need to switch to another branch urgently — maybe to review a teammate's PR or fix a production bug. You cannot switch branches with uncommitted changes if they conflict with the target branch. You also do not want to make a half-done commit. This is what git stash is for.

# Save your current uncommitted changes
git stash

# Now you can switch branches freely
git checkout main
git checkout -b fix/urgent-production-bug
# ... fix the bug, commit, push, open PR ...

# Go back to your feature branch
git checkout feature/your-branch

# Restore your stashed changes
git stash pop

# Other useful stash commands:
git stash list # See all stashed changes
git stash save "WIP: login" # Stash with a description
git stash apply # Restore without removing from stash
git stash drop # Delete the latest stash
git stash clear # Delete all stashes

Stash is a temporary holding area. Do not use it as long-term storage — if you have changes you want to keep, commit them on a branch. Use stash for the specific scenario of "I need to context-switch for a few minutes or hours and come back."

Preventing Conflicts in the First Place

The best conflict resolution is not needing to resolve conflicts at all. Here are strategies that reduce conflicts dramatically:

  • Keep branches short-lived. A branch that lives for two days has far fewer conflicts than one that lives for two weeks. Merge early, merge often.
  • Pull from main frequently. Run git fetch origin and git merge origin/main into your feature branch daily. This keeps your branch close to main and makes conflicts small when they do occur.
  • Communicate with your team. If two people are working on the same file, they should know about it. A quick message in Slack — "I am refactoring the auth module today" — prevents two people from making conflicting changes independently.
  • Keep files small and focused. A 2,000-line file has a much higher chance of conflicts than twenty 100-line files. If two people need to change the same module, smaller files let them work on different files within that module.
  • Use code owners. GitHub's CODEOWNERS file lets you assign specific team members to specific parts of the codebase. When a PR touches a file they own, they are automatically added as a reviewer. This ensures the right people review the right code and reduces conflicting approaches.

Final thought: Team Git is not just a set of commands — it is a communication system. Branch names tell your team what you are working on. Commit messages explain why changes were made. Pull requests start conversations about code quality. CI/CD pipelines enforce standards automatically. When you master these workflows, you are not just a better Git user — you are a better teammate. And that is what employers actually hire for.