git · branches · pull requests · CI · compose — a working-level course

Main is where finished work lands. Not where it happens.

You commit, you push to main, and it mostly works — until it doesn't. This page teaches the loop teams wrap around those commands: branching, pull requests, CI, conflict resolution, Docker Compose environments — and how to run the whole thing alone without losing its protections.

Start the course ↓
01 · the mental model

A graph of snapshots, not a timeline

Git stores your project as a graph. Every commit is a full snapshot plus a pointer to its parent. A branch is nothing but a movable label pointing at one commit.

main is a label. feature/login is a label. Creating a branch copies nothing and costs nothing — which is why teams branch constantly, even for twenty-minute experiments.

Three consequences unlock everything else on this page. Branching is free, so isolation is free. Merging is graph surgery — git combines two lines of history automatically wherever the changes don't overlap. And HEAD is just where you're standing; switching branches loses nothing, because commits stay in the graph. But checking out a raw commit hash instead of a branch name — from git log output, a CI link, or git checkout <hash> — detaches HEAD from any branch; commits made there are still real, but if you don't give them a branch (git switch -c newbranch or git checkout -b newbranch) before switching away, they become exactly the kind of "lost" commit git reflog exists to recover.

If you've ever been afraid of losing work: committed work is almost impossible to lose. git reflog records where HEAD has been — ninety days for entries still reachable, but only thirty days once they become unreachable (e.g. after a reset or amend), and it's that thirty-day window that matters for recovering a "lost" commit. The genuinely dangerous commands are the ones that touch uncommitted work (reset --hard, checkout -- file) — Git 2.23+ split checkout into git switch (change branches) and git restore (discard/restore file changes) to remove that ambiguity; this doc uses checkout throughout, but switch/restore are the newer, less error-prone equivalents. Commit early and often, and git becomes very forgiving.

You create a branch in a 2 GB repo. How much disk space does that use?

A branch is a pointer to a commit — one tiny file containing a hash. This is why "should I branch?" should never be a cost question.

Scroll: the “timeline” bends into a graph. The moving tag is a branch — just a label sliding to the newest commit.

02 · github flow

The seven-step loop teams run all day

This is GitHub Flow — the workflow most modern teams use. Step through it; the diagram replays the real shape of the history as you go.

why the loop shape matters Main is always releasable, because nothing enters it unreviewed or untested. Ten people work simultaneously without collisions, because each works isolated. And every change carries a paper trail — what changed, why, who reviewed it, what the tests said.

What a pull request actually is

A PR is three things at once. It's a diff — exactly what would change in main, line by line. It's a conversation — comments pinned to specific lines; you push fixes to the same branch and the PR updates itself. And it's a gate — with branch protection on, nothing reaches main except through a PR with green checks.

Branch names carry meaning: feature/… new capability, fix/… bug fixes, chore/… maintenance, hotfix/… urgent production repair. One branch = one reviewable unit of work, not "everything I did this week."

03 · getting back into main

Three merge strategies, one iron rule

Merge commit keeps the branch's full history — honest but noisy; every "wip" and "typo" commit lands in main's log.

Squash and merge compresses the whole branch into a single clean commit: "Add password reset (#214)". The most common default, and a good one to reach for unless you have a specific reason to want per-commit history on main — your messy 14 commits stay on the branch, main gets one tidy one.

Rebase and merge replays your commits on top of main for a perfectly linear history. Elegant — but rebase rewrites commit hashes, hence the iron rule:

the iron ruleNever rebase commits that others may have already pulled. Rebase your own unpushed work freely; never rewrite shared history.

Keeping a long branch current

git merge main    # pull main into your branch; adds a merge commit
git rebase main   # replay your commits on new main; linear

Either is fine on a branch only you touch. The point is to sync regularly so the final merge is small and boring instead of a week of divergence colliding at once.

04 · when code competes

A conflict is a question, not a catastrophe

config.py — merging feature/retry-tuning into your branchCONFLICT
<<<<<<< HEAD max_retries = 5 # you raised this for flaky API ======= max_retries = 3 # lowered per incident #88 >>>>>>> feature/retry-tuning

Two commits changed the same lines, and git refuses to guess which version you want. That's the entire event.

Git auto-merges everything that doesn't overlap — different files, different regions of the same file. Only genuinely overlapping edits stop the merge, and when they do, git writes both versions into the file between markers. The section under HEAD is your branch; below the ======= is the branch coming in. Your job: produce the correct final text, then

git add config.py
git merge --continue    # or: git rebase --continue

A rebase can pause more than once — once per replayed commit that conflicts — so resolve, add, continue may repeat several times before it reports there's nothing left to rebase.

  • Resolve toward intent, not text. Read why each side changed the line — sometimes the right answer is neither version. Try it in the panel: one of the three choices is better than the other two.
  • Someone else's code? Talk to them. Two minutes of conversation beats guessing. Convention: the branch author resolves, pulling in the other author when unsure.
  • Escape hatch: git merge --abort returns you to the exact pre-merge state; git rebase --abort does the same for a rebase gone sideways. Nothing is ruined.
  • Prevention beats cure: small PRs, frequent syncs with main, and not letting two people rewrite the same file the same week.
05 · continuous integration

The robot that checks the gate

CI runs your project's checks on every push and every PR — on a fresh machine, from exactly what's committed.

The configuration is a file in the repo itself. For GitHub Actions it lives in .github/workflows/:

name: ci
on:
  pull_request:
  push: { branches: [main] }
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: docker compose -f compose.yaml \
             -f compose.ci.yaml up --exit-code-from tests

Read it top to bottom: when (PRs and pushes to main), spin up a clean Linux box, check out the code, install, lint, test — then run the whole compose stack and test the pieces together. tests is a service defined in compose.ci.yaml (section 8) — its exit code decides whether this step passes.

The fresh-machine detail matters more than it looks. CI proves the project builds from scratch from what's committed. A file you forgot to add, a tool only your laptop has — CI catches it on day one, not during an emergency.

Combined with branch protection — "merging requires green checks" — CI makes one whole class of accident structurally impossible: merging code you never tested. The robot never gets tired and never skims a diff.

lint / format~10s
unit tests~40s
build~1m
integration (compose)~3m
deploy to stagingon merge

Cheapest checks first — fail in 10 seconds, not 5 minutes. “Push a bug” to watch the ladder stop the merge.

06 · two trust models

Branching vs forking

Same PR machinery, different trust question: do you have write access?

Branch — you're on the team, you have write access. Push branches straight to the shared repo, open PRs from branch → main inside it. This is your daily mode.

Fork — you don't have write access. Copy the whole repo to your own account, branch there, then open a PR from your fork back to the original. This is how a stranger can propose a fix to any open-source project without anyone handing over keys. Maintainers review it exactly like a teammate's PR.

On a fork you keep two remotes — origin (your copy) and upstream (the original) — and periodically sync:

git remote add upstream https://github.com/them/project.git
git fetch upstream
git merge upstream/main

When this matters to you: the day you hit a bug in an open-source library you use. Fork, branch, fix, PR to the maintainer.

You want to fix a typo in a popular open-source project's docs. What's the path?

Forking needs no permission from anyone. Your PR shows up for the maintainers exactly like a teammate's would.

07 · the wider landscape

GitFlow, GitHub Flow, trunk-based — a spectrum

Drag the slider. These are the three workflow families you'll meet in the wild; the diagram redraws the shape of history each one produces.

GitFlowGitHub FlowTrunk-based
08 · one stack, many environments

Compose override files, demystified

compose.yaml — basealways loaded
services:
  web:
    build: .
    depends_on: [db]
    environment:
      DATABASE_URL: postgres://app@db:5432/app
  db:
    image: postgres:16
    volumes: [dbdata:/var/lib/postgresql/data]
compose.override.yaml — devauto-loaded by `up`
services:
  web:
    volumes: [.:/app]     # live code, no rebuild
    ports: ["8000:8000"]
    environment: { DEBUG: "true" }
    command: npm run dev  # hot reload
compose.prod.yaml — prodexplicit only
services:
  web:
    image: registry.example.com/app:${TAG}
    restart: always
    environment:
      DEBUG: "false"
      SECRET_KEY: ${SECRET_KEY}  # injected, never committed

That ${SECRET_KEY} is Compose's own variable interpolation — resolved from the shell environment or a .env file Compose auto-loads from the project root, separate from any dotenv-loading your application does inside the container.

compose.ci.yaml — CIexplicit only
services:
  tests:
    build: .
    command: npm test
    depends_on: [web, db]
  db:
    volumes: []            # throwaway database
command for this environment

Compose merges multiple YAML files, later files overriding earlier ones. The base file says what's true everywhere; each environment adds a thin layer on top.

The filename compose.override.yaml is magic: plain docker compose up loads it automatically. That's why dev "just works" while prod must be requested explicitly — a safe default. Prod runs a built image, not your live source; secrets arrive as environment variables, never as committed text.

How this meshes with the git loop

The compose files are code, in the repo, on your branch. Change a service and that change rides your feature branch, appears in the PR diff, gets reviewed, and CI actually boots the new stack before it can merge. Environment differences live in override files and env vars — never in local edits you hope to remember.

The payoff for a multi-machine solo developer is exact: git clone + docker compose up = a working dev environment on any machine you sit down at.

one habitCommit a .env.example with placeholders; git-ignore the real .env. Secrets that enter git history are nearly impossible to scrub out later. This applies to both the Compose-level .env and any app-level .env — same habit, two different files.
09 · the solo adaptation

Drop the humans. Keep every protection.

Keep

  • Branch for anything non-trivial. Thirty seconds buys you: main always works, experiments are free, half-finished work never blocks an urgent fix. The moment you think "I'll just be careful" — branch.
  • PRs to yourself. No reviewer needed. You still get CI-before-merge, a self-review pass of the diff (reading a diff catches bugs that writing the code never does), and a searchable answer to "why did I change this?" six months out.
  • Branch protection on main. Require PRs and green checks — GitHub lets you require checks without requiring reviews. Now you cannot push broken code to main from either machine, even at 1 a.m. The single highest-value team practice for a solo dev.

Drop

  • Approval requirements, develop branches, release branches — GitHub Flow, solo edition.
  • Long-lived branches. You're your only integrator; merge within a day or two.

Change — the multi-machine ritual

The rejected pushes you've hit come from two machines each believing they're the source of truth. The fix is a ritual, not a tool: origin is the source of truth; machines are disposable caches.

  • Arriving at any machine: git fetch origin, then git status — see where this machine stands before touching anything.
  • Leaving any machine: commit — even "wip: half-done form validation" — and push the branch. An unpushed commit exists on exactly one hard drive.
  • Pushing a wip commit to your own feature branch is always safe: nothing depends on it, and squash-merge erases the mess later. That freedom is precisely what committing straight to main never gave you.
the one rebase caveatA branch you've pushed and then pulled onto a second machine counts as "shared" for the iron rule in chapter 3 — shared only with yourself, but shared all the same. git rebase main rewrites its commit hashes, so your next git push from either machine is rejected as non-fast-forward. When you're certain no one but you has touched the branch, force-push with git push --force-with-lease, never plain --force: force-with-lease refuses to overwrite the remote if it holds commits you haven't seen yet (a wip push from your other machine), which plain force would silently clobber.

You're leaving your desktop; you'll continue on the laptop tonight. The feature is half-broken. What do you do?

Wip commits on your own branch are free — squash-merge cleans them up later. (Stashes never leave the machine; that third option loses people regularly.)

Desktop and laptop never talk to each other — every sync goes through origin. Watch the commits travel.

10 · keep this open

The loop on one screen

momentcommand
start workgit checkout main && git pull
new branchgit checkout -b feature/name
save progressgit add -p && git commit -m "Do X"
first push of a branchgit push -u origin feature/name
sync branch with maingit merge main — or git rebase main if unshared
push after rebasing a pushed branchgit push --force-with-lease
see what's going ongit status · git log --oneline --graph --all
conflict: bail outgit merge --abort
conflict: finishfix files → git add …git merge --continue
after merge, clean upgit checkout main && git pull && git branch -d feature/name
find "lost" commitsgit reflog
dev stackdocker compose up (auto-loads override)
prod stackdocker compose -f compose.yaml -f compose.prod.yaml up -d

Glossary

origin
default name of the remote you cloned from; just a saved URL.
HEAD
pointer to the commit or branch you currently have checked out.
branch
a movable label on a commit; not a copy of anything.
origin/main
your local record of where main was on the server at last fetch.
fetch vs pull
fetch downloads commits without touching your files; pull = fetch + merge into your current branch.
fast-forward
a "merge" where the label simply moves forward because nothing diverged.
PR / merge request
a reviewed, gated request to merge one branch into another; same thing, two vendor names.
branch protection
server-side rules: require PRs, require passing checks, forbid force-push.
CI / CD
auto-run checks on every change / auto-ship what passes.
feature flag
a runtime switch letting merged code stay dormant in production.
fork
your own server-side copy of someone else's repo, used to propose changes without write access.
upstream
conventional name for the original repo a fork came from.
squash merge
collapsing a branch's commits into one commit on the target branch.
rebase
replaying commits onto a new base; rewrites history — unshared work only.
compose override file
an extra YAML merged over the base compose file to specialize one environment.