# Git With Other People - Branches, Pull Requests, and Not Stepping on Toes

> How real teams use Git: feature branches, keeping your work in sync with a moving main, and getting changes in through pull requests - without overwriting anyone.


---

# Git With Other People - Branches, Pull Requests, and Not Stepping on Toes

Working on Git alone is one thing. Then you join a team, and suddenly there are five other people committing
to the same project, a branch called `main` that everyone treats as sacred, and a thing called a "pull
request" that you're expected to "open" - and nobody explains any of it. The commands you know still work;
it's the *workflow* around them that's new and unspoken.

This guide is that missing explanation. By the end you'll know how to start a piece of work without
endangering anyone else's, keep your branch current as `main` moves under you, and get your changes
reviewed and merged the way professional teams actually do it. No new scary commands - just the patterns
that turn solo Git into team Git.

> ⏭️ **New to Git?** This guide assumes you can already `commit`, `push`, `branch`, and `merge`, and that
> you've met merge conflicts. If any of that is shaky, read
> [Git From Zero](/guides/git-from-zero) and then
> [Git, Explained Like You're a Human](/guides/git-explained-like-a-human) first - they build the
> foundation this one stands on.

## How to read this
- **Just joined a team and want the workflow?** Read in order - it follows the real life-cycle of one
  change, from branch to merged.
- **Stuck mid-sync right now?** Jump to the [team-sync cheat-card in Phase 2](02-staying-in-sync.md).

## The phases
1. **[The Feature-Branch Workflow](01-the-feature-branch-workflow.md)** - why nobody commits to `main`
   directly, and the branch → push → merge loop every change travels through.
2. **[Staying in Sync](02-staying-in-sync.md)** - keeping your branch current while `main` keeps moving,
   and handling the conflicts that come from a team, calmly.
3. **[Pull Requests & Review](03-pull-requests-and-review.md)** - what a PR actually is, how review
   works, merging cleanly, and tagging a release.

> Rewriting history - `rebase`, recovering lost commits with the reflog, and undoing work you've already
> pushed - is deliberately held back for the advanced guide (#4: Git Disaster Recovery). Here we stay on
> the safe, merge-based path that won't bite a teammate.


---

# The Feature-Branch Workflow - Why Nobody Touches main

Your first day on a team repo, someone says "just branch off `main` and open a PR when you're done." If
that sentence had three words you'd have to bluff your way past, you're in the right place. Underneath the
jargon is one simple, sane idea, and once you see it, the whole team workflow falls into place.

Let's start with the rule that surprises solo developers most: **on a team, you don't commit to `main`
directly.** Ever. Here's why - and what you do instead.

## Why `main` is sacred

**What `main` actually is on a team.** It's the one shared branch everyone agrees is *always working* -
the version you could ship right now. Other people pull from it constantly, and often it's what gets
deployed automatically. So if you commit half-finished work straight to `main`, you've just handed everyone
else broken code and maybe pushed a bug to production.

**What you do instead.** You make a **feature branch** - a private line of work that splits off from
`main`. You do all your committing there, in your own sandbox, where half-finished and experimental are
perfectly safe. When the work is done and reviewed, it gets merged back into `main` as one reviewed unit.

```mermaid
gitGraph
  commit id: "C1"
  commit id: "C2"
  branch feature
  checkout feature
  commit id: "A"
  commit id: "B"
  checkout main
  merge feature id: "M"
```

💡 **Key point.** A feature branch buys you isolation. Your messy middle is invisible to the team until
you choose to share it, and `main` never sees a broken state. That single habit is the foundation of every
team Git workflow.

## The loop every change travels

Here's the whole life-cycle of one piece of work. We'll spend the rest of this guide on the interesting
parts, but see the shape first:

```mermaid
flowchart LR
  B(git switch -c feature/x) --> W(work + commit)
  W --> P(git push -u origin feature/x)
  P --> PR(open a pull request)
  PR --> R(review)
  R --> M(merge via PR)
  M --> D(delete branch)
```

Let's walk the first half now (the PR and review half is [Phase 3](03-pull-requests-and-review.md)).

## Step 1: Branch off the latest `main`

Before you branch, make sure your `main` is current - you want to build on what the team has, not on
last week's copy:
```console
$ git switch main
$ git pull
Already up to date.
$ git switch -c feature/cart-totals
Switched to a new branch 'feature/cart-totals'
```
*What just happened:* You moved to `main`, pulled the latest from the team, then created `feature/cart-totals`
*from that up-to-date point* and switched onto it (`-c` = create). Your new branch starts life identical to
`main`; from here, your commits land on it alone.

📝 **Terminology.** *Feature branch* is the common name even when the work is a bug fix or a tweak - it just
means "a short-lived branch for one unit of work, off `main`."

## Step 2: Do the work, commit as you go

This is ordinary Git - the loop you already know. Edit, `add`, `commit`, as many times as makes sense:
```console
$ git add pricing.js
$ git commit -m "Calculate cart subtotal before tax"
[feature/cart-totals 9a1b2c3] Calculate cart subtotal before tax
 1 file changed, 14 insertions(+)
```
*What just happened:* A normal commit - but notice it landed on `feature/cart-totals`, not `main`. `main`
hasn't moved; your work is accumulating safely on your own branch. Commit in small, sensible steps; it
makes the review later much easier.

## Step 3: Push your branch so others can see it

Your branch only exists on your laptop until you push it:
```console
$ git push -u origin feature/cart-totals
Enumerating objects: 5, done.
Writing objects: 100% (5/5), 612 bytes | 612.00 KiB/s, done.
remote:
remote: Create a pull request for 'feature/cart-totals' on GitHub by visiting:
remote:      https://github.com/acme/shop/pull/new/feature/cart-totals
remote:
To github.com:acme/shop.git
 * [new branch]      feature/cart-totals -> feature/cart-totals
branch 'feature/cart-totals' set up to track 'origin/feature/cart-totals'.
```
*What just happened:* The first push of a new branch needs `-u origin <branch>` - it creates the branch on
GitHub and links your local branch to it, so future `git push` and `git pull` need no arguments (more on
that link in [Phase 2](02-staying-in-sync.md)). Notice GitHub helpfully printed a URL to open a pull
request - that's your on-ramp to Phase 3.

**Why push before you're finished?** Pushing isn't merging. It backs your work up off your laptop and lets
teammates see progress - but it changes nothing in `main`. Push early and often; it's free safety.

## Naming branches so humans can read them

Branch names are how your team skims who's doing what. Most teams use a short prefix and a hyphenated
description:
```text
   feature/cart-totals       a new capability
   fix/login-redirect-loop   a bug fix
   chore/upgrade-eslint      maintenance, deps, tooling
```
Pick whatever convention your team already uses - consistency matters more than the exact words. The goal
is that a name tells everyone what the branch is *for* at a glance.

## Keep junk out of the shared repo: `.gitignore`

The moment work is shared, files that are fine on your machine become noise for everyone else - your
editor's settings, downloaded dependencies, secret keys, build output. A **`.gitignore`** file tells Git
to pretend certain files don't exist, so they never get committed.

Create a file named `.gitignore` in the repo root listing patterns to ignore:
```text
node_modules/      # downloaded dependencies - huge, and re-installable
dist/              # build output - generated, not source
.env               # secrets and local config - NEVER commit these
*.log              # log files
.DS_Store          # macOS folder clutter
```
*What just happened:* Anything matching these patterns drops out of `git status` entirely - Git stops
offering to track it, so you can't commit it by accident.

⚠️ **Gotcha.** `.gitignore` only ignores files Git isn't *already* tracking. If a file was committed
*before* you ignored it (the classic case: a `.env` full of secrets), adding it to `.gitignore` does
nothing - it stays tracked. You have to stop tracking it with `git rm --cached .env` and commit that. And
if a secret was ever pushed, treat it as compromised and rotate it - *removing it from the latest commit
doesn't erase it from history.* (Scrubbing secrets out of history is advanced-guide territory.)

## The whole picture so far

You now have the first half of the team loop: branch off an up-to-date `main`, commit your work in
isolation, and push the branch so it's backed up and visible - all without `main` ever seeing an unfinished
state. The catch is that `main` doesn't sit still while you work. Keeping up with it is the next phase.

## Recap

1. **Never commit to `main` directly** - it's the always-working, shared, often-deployed branch.
2. **Make a feature branch** off an up-to-date `main` for each unit of work: `git switch -c feature/x`.
3. **Commit freely** on your branch; `main` is untouched until you merge.
4. **Push with `-u origin <branch>`** the first time - it's backup and visibility, not merging.
5. **Name branches** so humans can read them; use **`.gitignore`** to keep junk and secrets out.

Watch it animated: [branching](/explainers/Branching.dc.html)

## Try it yourself

Run commands and watch the history graph build - `commit -m "first"`, `branch dev`, `checkout dev`, `commit -m "work"`, `checkout main`, `merge dev`:

```playground-git
```


---

# Staying in Sync - Keeping Up With a Moving main

Here's the thing solo Git never prepares you for: while you're heads-down on your feature branch, the rest
of the team keeps merging into `main`. Every hour you work, your branch drifts a little further from the
"real" `main`. Wait too long and merging back becomes a painful tangle of conflicts.

The fix isn't to work faster - it's to **sync often**, in small doses. This phase shows you how to read how
far you've drifted, fold the latest `main` into your branch regularly, and stay calm when two people touch
the same lines.

## The team-sync cheat-card

> **Stuck right now? Find your situation, then read the section below.**

| Situation | The calm move |
|---|---|
| "Is my branch behind `main`?" | `git fetch`, then `git status` / `git log --oneline main..origin/main` (§2) |
| "Pull in the latest `main`" | `git switch main && git pull`, then `git switch <branch> && git merge main` (§3) |
| "Conflict while merging `main` in" | Resolve the files, `git add`, `git commit` - or `git merge --abort` to back out (§4) |
| "`push` rejected - fetch first" | `git pull`, resolve if needed, then `git push` (§5) |
| "What does `-u` / tracking even mean?" | It links your branch to its remote twin so bare `push`/`pull` work (§1) |

---

## 1. Tracking branches - the link that makes `push`/`pull` "just work"

**What it actually is.** When you ran `git push -u origin feature/cart-totals` in Phase 1, the `-u`
("set upstream") created a *link* between your local branch and its copy on GitHub, `origin/feature/cart-totals`.
Git calls that copy a **remote-tracking branch** - your local bookmark of where the remote's version is.

**What it does in real life.** Because of that link, `git push` and `git pull` know where to go with no
arguments, and `git status` can tell you how you compare to the remote:
```console
$ git status
On branch feature/cart-totals
Your branch is ahead of 'origin/feature/cart-totals' by 2 commits.
  (use "git push" to publish your local commits)
```
*What just happened:* Git compared your branch to its remote twin and reported the gap - 2 commits it
hasn't seen. "Ahead" means local work to push; "behind" means remote work to pull; "diverged" means both,
and you'll need to reconcile.

## 2. See how far you've drifted from `main`

Your `status` line compares you to *your own branch's* remote - not to `main`. To check whether `main`
itself has moved on without you, first download the latest, then compare:
```console
$ git fetch
remote: Enumerating objects: 12, done.
From github.com:acme/shop
   e4f5g6h..a7b8c9d  main -> origin/main
$ git log --oneline main..origin/main
a7b8c9d Add promo-code field
3f1e2d0 Fix currency rounding
```
*What just happened:* `git fetch` quietly downloaded everyone's new commits and updated your bookmark of
`origin/main` - it touched none of your files (that's what makes it safe to run anytime). The `log` command
then asked "what's in `origin/main` that my `main` doesn't have?" - two commits.

> ⏭️ Fuzzy on `fetch` vs `pull` or what `origin/main` is? They're covered in depth in
> [Git, Explained Like You're a Human](/guides/git-explained-like-a-human) - this guide builds on that.

## 3. Fold the latest `main` into your branch

When `main` has moved, bring its new commits *into your feature branch* so you're building on current code
and you discover any clashes now, while they're small:
```console
$ git switch main
$ git pull                       # get main fully up to date locally
Updating e4f5g6h..a7b8c9d
Fast-forward
$ git switch feature/cart-totals
$ git merge main                 # fold those new commits into your branch
Merge made by the 'ort' strategy.
 promo.js | 22 ++++++++++++++++++
 1 file changed, 22 insertions(+)
```
*What just happened:* You updated local `main` from the remote, switched back to your feature branch, and
merged `main` into it - your branch now has the team's recent work *plus* your own, so there's little left
to reconcile when it's time to merge back. (`'ort'` is just Git's default merge strategy name; nothing to
configure.)

💡 **Key point.** Do this regularly - every morning, or whenever you notice `main` moved. Frequent small
merges beat one giant end-of-week merge every time; divergence is what makes conflicts hurt, and syncing
often keeps divergence tiny.

## 4. When folding in `main` causes a conflict

Sometimes a teammate changed the same lines you did, and the merge in step 3 stops:
```console
$ git merge main
Auto-merging pricing.js
CONFLICT (content): Merge conflict in pricing.js
Automatic merge failed; fix conflicts and then commit the result.
```
**What's actually happening.** Nothing is broken. Two commits changed the same lines and Git won't guess
which wins - it paused and is asking you to decide. This is the *good* outcome of syncing often: you're
resolving one small conflict now instead of twenty later.

**The calm fix.** Open the conflicted file, keep the version you want, delete the `<<<<<<<` / `=======` /
`>>>>>>>` marker lines, then stage and commit:
```console
$ git add pricing.js
$ git commit            # completes the merge; Git pre-fills the message
```
*What just happened:* You told Git the final text, removed the markers, and committed - finishing the merge
it had paused. Your branch is now both up to date with `main` *and* conflict-free.

**The escape hatch.** Not ready to deal with it? `git merge --abort` returns everything to exactly how it
was before you started the merge. Safe anytime you're mid-conflict and want out.

> ⏭️ The mechanics of reading conflict markers are walked through step-by-step in
> [Git, Explained Like You're a Human → When It Breaks](/guides/git-explained-like-a-human). The *team*
> difference is only this: conflicts come from other people's commits, so the cure is syncing often.

## 5. "Updates were rejected - fetch first"

**The situation.** You go to push and Git refuses:
```console
$ git push
 ! [rejected]        feature/cart-totals -> feature/cart-totals (fetch first)
error: failed to push some refs to 'github.com:acme/shop.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally.
```
**What's actually happening.** Someone (or you, from another machine) pushed commits to this branch that you
don't have yet. Git refuses to overwrite work it can see you haven't seen. **This is a safety feature, not
a failure** - and it's the wall that protects teammates from being silently clobbered.

**The calm fix.** Bring their commits down, then push:
```console
$ git pull            # fetch + merge their commits into yours
$ git push
```
*What just happened:* `git pull` merged the remote's commits into your branch (resolve a conflict here just
like §4 if one appears), so both sides agree again - and the push goes through.

⚠️ **Gotcha - never reach for `--force` to make a rejection go away.** `git push --force` *does* silence
the error, by overwriting whatever was on the remote - including a teammate's commits, permanently. On a
shared branch that's how you ruin someone's afternoon. The straight fix for a rejection is always `pull`,
then `push`. (There's a genuinely safer cousin, `--force-with-lease`, used when deliberately rewriting your
*own* branch - that belongs with the rewriting-history material in the advanced guide.)

## A note on `rebase`

You'll hear teammates say "just rebase onto `main`" as an alternative to the merge in §3. Rebase *rewrites*
your commits - tidier history, but a sharp tool: used wrong on a shared branch it rewrites history other
people have, exactly the kind of mess that ruins days. We cover it properly, with its safety rules, in the
advanced guide (#4: Git Disaster Recovery); until then, the merge-based sync here is completely correct and
won't bite anyone.

## Recap

1. **Tracking branches** link your local branch to its remote twin, so bare `push`/`pull` work and
   `status` can tell you ahead/behind/diverged.
2. **`git fetch` + `git log main..origin/main`** shows how far `main` has moved without you.
3. **Fold `main` into your branch often** (`merge main`) - small frequent syncs keep conflicts tiny.
4. **Conflicts are an unfinished merge waiting on you** - resolve and commit, or `merge --abort`.
5. **A rejected push means pull first** - never `--force` a shared branch.

You can now keep your branch healthy and current while the team moves around you. The last step is getting
your finished work *into* `main` - through a pull request.


---

# Pull Requests & Review - Getting Your Work Into main

Your branch is done, committed, pushed, and in sync with `main`. Now for the final step nobody quite
explains: getting it *into* `main`. On a team you almost never merge your own branch from the command line.
Instead you open a **pull request** - and if that phrase has always been a bit of a mystery, this phase
clears it up completely.

## What a pull request actually is

**What it actually is.** A pull request (PR) is you formally saying: *"Here are the commits on my branch.
Please review them and merge them into `main`."* It's a request, with a discussion attached.

**The part that confuses everyone:** a pull request is **not a Git feature.** Git has no `pull request`
command. A PR is a feature of the *website* - GitHub, GitLab, Bitbucket - built *on top* of Git. It wraps
your branch in a web page where people can see your changes, comment line by line, request tweaks, approve,
and finally click a button to merge. Git moves the commits; the website adds the conversation and the
safety of review.

```mermaid
flowchart LR
  B(feature/cart-totals<br/>3 commits) -->|opens a PR| PR(A pull request - a web page that:<br/>• shows your branch's diff vs main<br/>• collects review comments + approval<br/>• offers a Merge button into main<br/>• is NOT a git command - it's a GitHub thing)
```

📝 **Terminology.** GitHub and Bitbucket say *pull request* (PR); GitLab says *merge request* (MR). Same
idea, different name.

## Step 1: Open the PR

After you push a branch, GitHub shows a **"Compare & pull request"** button (and you saw it print a PR URL
in [Phase 1](01-the-feature-branch-workflow.md)). Click it, and you'll fill in:

```text
   Base: main   ←   Compare: feature/cart-totals     (merge YOUR branch INTO main)

   Title:        Add cart subtotal and tax line
   Description:  What changed and why. Link the ticket. Note anything
                 reviewers should look at closely or test.
```

**Write the description for a tired reviewer.** A good PR description says *what* changed, *why*, and *how
to check it*. The reviewer wasn't in your head for the last two days - three sentences of context saves a
dozen back-and-forth comments.

💡 **Keep PRs small.** A 40-line PR gets a careful review in minutes; a 2,000-line PR gets a nervous
"looks good" and a rubber stamp. Smaller branches merged more often are easier to review, easier to
sync, and far less likely to hide a bug.

## Step 2: Respond to review

A teammate reads your PR and leaves comments - questions, suggestions, "can you rename this?" You don't
open a new PR to address them; you just **commit more to the same branch and push**:
```console
$ git add pricing.js
$ git commit -m "Rename helper for clarity (review feedback)"
$ git push
```
*What just happened:* Because your branch is linked to the PR, those new commits appear in the PR
automatically - the reviewer sees the update in place. A PR is a *living view* of your branch, not a
one-time snapshot. Round-trip until the reviewer approves.

🪖 **War story.** My first PR was 1,800 lines across 30 files. The reviewer sat on it for three days,
wrote "I trust you 🤷," and approved without really reading it - the review did *nothing*. The next one I
split into five small PRs; each got real comments that caught real bugs. Small PRs aren't politeness;
they're the only size review actually works on.

## Step 3: Merge

Once approved, you merge - usually by clicking the merge button on the PR. GitHub offers three flavors, and
teams pick one as their norm:

| Button | What it does | Feels like |
|---|---|---|
| **Create a merge commit** | Adds all your branch's commits to `main`, plus one merge commit tying them together | Full history, branch shape preserved |
| **Squash and merge** | Combines your whole branch into **one** tidy commit on `main` | Clean, one-commit-per-feature `main` (very common) |
| **Rebase and merge** | Replays your commits onto `main` individually, no merge commit | Linear history, no merge bubbles |

*What just happened:* GitHub performs the merge on the server and moves `main` forward to include your
work. Unsure which button? **Ask what your team uses** - many teams default to *Squash and merge* for a
clean `main`.

## Step 4: Clean up and reset for the next thing

After the merge, your feature branch has done its job. Delete it (GitHub offers a **"Delete branch"** button
right after merging - take it), then update your local world:
```console
$ git switch main
$ git pull                       # bring the just-merged work into local main
$ git branch -d feature/cart-totals
Deleted branch feature/cart-totals (was 9a1b2c3).
```
*What just happened:* You switched to `main`, pulled in your merged feature, and deleted the local branch
(`-d` only deletes branches already merged - a safety check against dropping unmerged work). You're back
on a clean, current `main`, ready for the next task.

⚠️ **Gotcha.** Don't keep working on a feature branch *after* it's been merged and deleted. Start each new
piece of work from a fresh branch off the updated `main` (Phase 1, Step 1). Reusing an old merged branch is
a reliable way to resurrect confusing history.

## When GitHub says it can't merge automatically

Sometimes the PR shows **"This branch has conflicts that must be resolved"** - `main` changed under you in
a way that clashes with your branch. GitHub can't guess the resolution, so you fix it locally with the exact
move from [Phase 2 §3–4](02-staying-in-sync.md):
```console
$ git switch main && git pull          # get the latest main
$ git switch feature/cart-totals
$ git merge main                       # resolve the conflict here, then commit
$ git push                             # the PR updates; the conflict clears
```
*What just happened:* You folded the latest `main` into your branch, resolved the clash locally, and
pushed - which updates the PR and re-enables its merge button. Same conflict skill you already have, just
triggered by the PR.

## Tagging a release

When a particular commit on `main` is a version worth naming - a release - you mark it with a **tag**. A tag
is a permanent, human-friendly label on one commit (unlike a branch, it doesn't move):
```console
$ git switch main && git pull
$ git tag -a v1.2.0 -m "Cart totals and promo codes"
$ git push origin v1.2.0
```
*What just happened:* `git tag -a` created an *annotated* tag `v1.2.0` (recording who tagged it, when, and
why) on the current `main` commit. Tags don't push with normal `git push`, so you send it explicitly. On
GitHub, that tag can become a **Release** - a download page with notes.

📝 **Terminology.** A **tag** names one commit forever (e.g. `v1.2.0`); a **release** is GitHub's page built
on a tag, with notes and downloadable assets. The version numbers themselves (what `1.2.0` means) follow a
convention called *semantic versioning* - a topic of its own.

## You can work on a team now

Step back and see the whole lap you can now run: branch off a current `main`, commit in isolation, sync as
`main` moves, open a PR, take review, merge cleanly, delete the branch, and tag a release when it ships.
That's the daily rhythm of professional Git - none of it new commands, just the workflow nobody writes
down.

**Where to go next.** You've stayed deliberately on the safe, merge-based path. The advanced guide -
**Git Disaster Recovery** - picks up the sharp tools: `rebase` used safely, recovering commits with the
**reflog**, and undoing work you've *already pushed*. That's the last rung, for when something has truly
gone sideways.

## Recap

1. A **pull request** is a website feature (not a Git command) that wraps your branch in review +
   discussion and offers a merge button into `main`.
2. **Open** a PR after pushing; write a description for a tired reviewer; **keep it small.**
3. **Respond to review** by pushing more commits to the same branch - the PR updates itself.
4. **Merge** via merge-commit / squash / rebase (use your team's norm), then **delete the branch** and
   `git switch main && git pull`.
5. Fix PR conflicts by **merging `main` into your branch locally**; **tag** named versions with
   `git tag -a` and push the tag.
