Trunk-based Git for teams
The trunk-based Git workflow that turns everyday commands into team shipping discipline: branch off main, squash-merge back, keep main's history one commit per change.
You have typed git add, git commit, and git push since the start of this course.
So far they’ve been bookkeeping: the thing you do after the real work, to save your place.
When you’re the only person in a repository, that’s all they need to be.
The history can be a swamp of “wip”, “fix”, and “ok now it works”, and no one is hurt, because you’re the only one reading it.
The moment a second person commits to the same repository, Git stops being bookkeeping and starts deciding things.
It decides whether merging to main means “this is deployable” or “this might integrate, who knows.”
It decides how much a reviewer has to wade through to approve your change.
It decides whether the automated checks you add next chapter can block a bad merge or merely decorate it.
None of that needs new commands; it needs the handful you already know, used with intent.
By the end of this lesson you’ll run the everyday team loop end to end, set five lines of configuration once and forget them, and understand why main’s history should read like a changelog.
The four Git objects behind the workflow
Section titled “The four Git objects behind the workflow”You run these commands every day, so this isn’t about the commands. It’s about what each object is structurally, because every decision later in the lesson rests on these four definitions.
A commit is a snapshot of your entire project at one point in time, stamped with an author, a message, and a pointer to its parent. The word to hold onto is snapshot. A commit is not a diff, a list of changed lines; Git stores the full tree of files as it stood and computes a diff only when you ask to see one. That matters later when you move commits around: each one is a complete, self-contained snapshot, which is exactly what makes moving it safe.
A branch is a movable pointer to one commit. Creating a branch copies no files and duplicates no history; it writes one line into a small file, the 40-character hash of the commit you’re pointing at. A branch is as cheap as a bookmark, and that is why the workflow works. If branches were expensive you’d hoard them for weeks; because they cost nothing, you can treat them as disposable scratchpads, create one for a day’s work, fold it onto the mainline, and throw it away.
The staging area , also called the index , is the set of changes that will go into your next commit.
Running git add saves nothing; it chooses what the next commit will contain.
It’s easy to treat this as a rubber stamp before git commit, but it’s a slicing tool, and using it well is one of the clearest signs of someone who understands Git.
It gets its own section below.
A remote is a named URL pointing at a hosted copy of the repository, conventionally named origin and, for this course, on GitHub.
Your local repository and the remote are separate repositories that share history; push and fetch carry commits between them.
Nothing you do locally reaches a teammate until you push, and nothing they do reaches you until you fetch.
%%{init: {'themeCSS': '.commit-label, .commit-label tspan { font-size: 13px !important; } .branch-label, .branch-label tspan, text.branchLabel, .label tspan { font-size: 14px !important; }'} }%%
gitGraph
commit id: "scaffold"
commit id: "auth"
commit id: "invoices"
branch feat/invoice-status
commit id: "status filter" A branch is a label pointing at a commit. feat/invoice-status and main share every commit up to the fork. The remote on GitHub mirrors this shape, and push and fetch move commits between the two.
Two more terms you’ll see without further fanfare. Your working tree is the files on disk right now, including edits you haven’t staged. HEAD is the pointer to where you currently are, the commit your next commit will attach to.
The everyday team loop
Section titled “The everyday team loop”Here is the full cycle for shipping one change on a team.
We’ll follow one running example throughout the lesson: adding a status filter to the invoices list, on a branch called feat/invoice-status.
-
Branch off
mainfor the change you’re about to make.Terminal window git checkout -b feat/invoice-status -
Do the work: edit files, run it, get it right.
-
Stage the changes that belong to this change, then commit them.
Terminal window git add -pgit commit -m "Add status filter to invoices list" -
Push the branch to GitHub.
Terminal window git push -u origin feat/invoice-status -
Open a pull request from
feat/invoice-statusintomain, and get it reviewed. -
While the branch waits for review,
mainmoves on. Catch your branch up to it.Terminal window git pull --rebase origin maingit push --force-with-lease -
Squash-merge the pull request. Your branch’s work lands on
mainas a single commit, and the branch is deleted.
You’ll run this loop dozens of times a week. The reflex underneath all of it is one branch per pull request, one pull request per logical change. A logical change is one thing a reviewer can hold in their head and approve in a sitting: a feature, a fix, a focused refactor. It is not three unrelated things bundled together because they happened to share your working tree.
Opening and reviewing that pull request is a craft of its own, covered in a later lesson; for now, treat step 5 as a black box, a button on GitHub that proposes merging your branch into main.
Steps 3, 6, and 7 are where the real work is, so we’ll take them in turn.
Stage by hunk with git add -p
Section titled “Stage by hunk with git add -p”You probably know git add . to stage everything and git add path/to/file to stage one file.
Both are coarse.
To stage less than a whole file, use git add -p, where -p is for patch.
This comes up constantly.
You sit down to add the status filter and spot a date formatting wrong two functions up, so you fix it while you’re there.
Now your working tree holds two unrelated changes in the same file.
If you git add . and commit, they land together: one commit doing two things, and a reviewer who can’t tell whether the date fix belongs to the filter feature.
If the filter is later reverted, the date fix goes with it.
git add -p lets you split them.
Instead of staging whole files, Git walks you through each hunk and asks whether to stage it.
Say yes to the hunks that belong to the filter, no to the date fix, then commit.
The filter is now one clean commit, and the date fix is still in your working tree, ready to become its own commit or pull request.
$ git add -pdiff --git a/components/invoice-row.tsx b/components/invoice-row.tsx@@ -8,7 +8,7 @@ export function InvoiceRow({ invoice }: Props) {- <td>{invoice.dueDate.toString()}</td>+ <td>{formatDate(invoice.dueDate)}</td>Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
@@ -20,6 +20,9 @@ export function InvoiceList({ invoices }: Props) {+ const [status, setStatus] = useState<Status | 'all'>('all');+ const visible =+ status === 'all'+ ? invoices+ : invoices.filter((invoice) => invoice.status === status);Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
$ git commit -m "Add status filter to invoices list"Two unrelated edits sit in your working tree: the date-format fix and the status filter. Patch mode walks them one hunk at a time so you can separate them.
$ git add -pdiff --git a/components/invoice-row.tsx b/components/invoice-row.tsx@@ -8,7 +8,7 @@ export function InvoiceRow({ invoice }: Props) {- <td>{invoice.dueDate.toString()}</td>+ <td>{formatDate(invoice.dueDate)}</td>Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
@@ -20,6 +20,9 @@ export function InvoiceList({ invoices }: Props) {+ const [status, setStatus] = useState<Status | 'all'>('all');+ const visible =+ status === 'all'+ ? invoices+ : invoices.filter((invoice) => invoice.status === status);Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
$ git commit -m "Add status filter to invoices list"The first hunk is the date fix, which doesn’t belong to this feature. Answer n to skip it; it stays in your working tree for its own commit later.
$ git add -pdiff --git a/components/invoice-row.tsx b/components/invoice-row.tsx@@ -8,7 +8,7 @@ export function InvoiceRow({ invoice }: Props) {- <td>{invoice.dueDate.toString()}</td>+ <td>{formatDate(invoice.dueDate)}</td>Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
@@ -20,6 +20,9 @@ export function InvoiceList({ invoices }: Props) {+ const [status, setStatus] = useState<Status | 'all'>('all');+ const visible =+ status === 'all'+ ? invoices+ : invoices.filter((invoice) => invoice.status === status);Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
$ git commit -m "Add status filter to invoices list"The second hunk is the filter work. Answer y to stage it; only this slice is queued for the commit.
$ git add -pdiff --git a/components/invoice-row.tsx b/components/invoice-row.tsx@@ -8,7 +8,7 @@ export function InvoiceRow({ invoice }: Props) {- <td>{invoice.dueDate.toString()}</td>+ <td>{formatDate(invoice.dueDate)}</td>Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
@@ -20,6 +20,9 @@ export function InvoiceList({ invoices }: Props) {+ const [status, setStatus] = useState<Status | 'all'>('all');+ const visible =+ status === 'all'+ ? invoices+ : invoices.filter((invoice) => invoice.status === status);Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
$ git commit -m "Add status filter to invoices list"The commit captures only the staged (green) hunk: one commit that is exactly the status filter. The date fix is untouched, still in your working tree.
This is what “one logical change per commit” means in practice.
The staging area makes it possible, and git add -p makes it easy: staging becomes the moment you decide what each commit contains.
Merge, rebase, and fast-forward
Section titled “Merge, rebase, and fast-forward”Now step 6.
While your branch sat in review, teammates merged their own work, so main moved forward and your branch is now built on an older version of it: it has fallen behind.
Before you can merge, you have to bring main’s new commits into your branch and resolve any conflicts.
There are two ways to do that, and they leave very different shapes of history.
The first is merge.
Run from your branch, git merge main ties the two diverged lines of history together with a new merge commit that has two parents, one from each side.
Nothing moves; both histories are preserved exactly as they happened, and the graph forks and then rejoins at the merge commit.
The second is rebase.
git rebase main sets your commits aside, moves your branch on top of main’s latest commit, and replays your commits one at a time onto that new base.
The result is a straight line: main’s history, then your commits, no fork.
The catch is that replayed commits are brand new commits: same changes, same messages, but new parents and therefore new hashes.
Rebase doesn’t move your commits, it copies them onto a new base and abandons the originals.
Step 1, the divergence. While your branch waited for review, main gained two commits (C4, C5) from teammates. Your feat/invoice-status branch (a1, b2) still forks from the older C2, so it has fallen behind.
Step 2, merge. git merge ties the two histories together with a merge commit M that has two parents, one from each side. Both lines are preserved exactly as they happened: the graph forks and rejoins, and your commits keep their original hashes.
Step 3, rebase. git rebase replays your commits on top of the latest main, leaving a straight line. The originals (faded) are abandoned: the replayed a1' and b2' are brand new commits with new hashes.
Both end states are correct: both integrate main’s changes with yours.
They differ only in the shape they leave behind, which is what the next section optimizes for.
Merge forks and rejoins; rebase stays in a line.
Reading about rebase is not the same as doing one.
The sandbox below is a real Git environment in your browser, set up with the same divergence as the sequence above: a feature branch on an older main, with main already moved ahead.
main has moved ahead of your feature branch. Type git rebase main and watch your two feature commits lift off and replay on top of main, turning the fork into a straight line.
One more term.
When main hasn’t moved since you branched, there’s nothing to replay and nothing to tie together; your commits already sit directly on main’s tip.
Integrating then is a fast-forward : Git just slides main’s pointer forward to your commits, no merge commit and no rebase.
It’s the cleanest case, and the workflow you’re about to learn arranges for it on main almost every time.
Rebase locally, squash-merge on the PR
Section titled “Rebase locally, squash-merge on the PR”Rebase locally is step 6 of the loop.
git pull --rebase origin main fetches main’s new commits and replays your branch’s commits on top of them, keeping your branch a straight line built on the latest main.
A plain git pull instead merges main into your branch, scattering a “Merge branch ‘main’ into feat/invoice-status” commit through your history every time you sync.
The rebase keeps your branch clean while you work.
Squash-merge is step 7, where the cleanup happens.
While you worked, your branch piled up honest, messy commits: “wip”, “actually fix the filter”, “address review comments”, “fix typo”.
That mess is fine on your own branch, but you don’t want it on main.
GitHub’s “Squash and merge” button collapses the whole pull request, however many commits it holds, into a single commit on main with a message you write.
What lands is one commit that says “Add status filter to invoices list,” and that is the only trace of your branch main ever sees.
%%{init: {'themeCSS': '.commit-label, .commit-label tspan { font-size: 13px !important; } .branch-label, .branch-label tspan, text.branchLabel, .label tspan { font-size: 14px !important; }'} }%%
gitGraph
commit id: "invoices"
branch feat/invoice-status
commit id: "wip"
commit id: "fix the filter"
checkout main
commit id: "teammate"
checkout feat/invoice-status
merge main id: "Merge branch main"
commit id: "address review"
commit id: "fix typo" Your branch: honest, messy, and yours. The “wip” and “fix typo” commits, and the stray Merge branch main from a git pull without --rebase, are all noise nobody grades.
%%{init: {'themeCSS': '.commit-label, .commit-label tspan { font-size: 13px !important; } .branch-label, .branch-label tspan, text.branchLabel, .label tspan { font-size: 14px !important; }'} }%%
gitGraph
commit id: "invoices"
commit id: "teammate"
commit id: "Add status filter to invoices list" main: one commit per shipped change. The whole branch above collapses into a single commit, and its internal noise never crosses over.
When main is one commit per shipped change, every line of git log reads like a changelog entry, and every commit is a complete, deployable change with no broken half-feature sitting in the mainline.
It also pays off when something breaks: the recovery tools you’ll meet next lesson operate on whole commits, so they act on a single pull request’s worth of work instead of getting lost in a thicket of “wip” commits.
Clean history isn’t tidiness for its own sake; it’s what makes those tools usable.
When does a real merge commit, one that preserves your branch’s individual commits on main, earn its weight?
Rarely.
The case is a deliberate, multi-commit refactor where each commit is a meaningful, self-contained step you want recorded separately: “rename the type,” then “move the file,” then “update the call sites.”
In normal feature work, where your branch’s commits are scratchpad noise, the default is squash, every time.
The trunk-based workflow (and why not Git Flow)
Section titled “The trunk-based workflow (and why not Git Flow)”That whole loop has a name.
One long-lived main with short feature branches that live for hours or days and then collapse back in is the trunk-based workflow, and its practical, GitHub-shaped form is called GitHub Flow .
The model is deliberately small.
One branch lives forever, the trunk , which is main; every other branch is short-lived and branches off it.
Releasing isn’t a separate ceremony on a separate branch, it’s deploying main.
A hotfix isn’t a special branch type, it’s a normal feature branch with a fast pull request against main.
The whole system is “branch off main, do the work, squash-merge back, deploy main.”
Older Git tutorials describe a more elaborate scheme called Git Flow: a permanent develop branch alongside main, plus release/* and hotfix/* branches with rules about which merges into which.
It’s worth recognizing when you meet it in an old post, but it belongs to a different era.
Git Flow was built for a world where shipping was a quarterly event gated behind a manual QA team, and those long-lived branches were the staging ground for a release that took weeks to assemble.
In 2026, every problem they solved is solved better elsewhere: a preview deployment gives every pull request a live, testable URL, automated checks gate quality on every merge (the next chapter), and feature flags let you merge risky code to main switched off.
With those in place, develop, release, and hotfix are three extra long-lived branches to keep in sync for no benefit.
%%{init: {'themeCSS': '.commit-label, .commit-label tspan { font-size: 13px !important; } .branch-label, .branch-label tspan, text.branchLabel, .label tspan { font-size: 14px !important; }'} }%%
gitGraph
commit id: "v1.1"
branch develop
commit id: "d1"
branch feature/login
commit id: "f1"
commit id: "f2"
checkout develop
merge feature/login id: "merge feat"
branch release/1.2
commit id: "r1"
checkout main
branch hotfix/crash
commit id: "h1"
checkout main
merge hotfix/crash id: "v1.1.1" tag: "v1.1.1"
checkout develop
merge hotfix/crash id: "back-merge"
checkout release/1.2
commit id: "r2"
checkout main
merge release/1.2 id: "v1.2" tag: "v1.2"
checkout develop
merge release/1.2 id: "sync develop" Git Flow: five lanes (main, develop, release/*, feature/*, hotfix/*) and cross-merges to keep them in sync, built for quarterly, QA-gated releases.
%%{init: {'themeCSS': '.commit-label, .commit-label tspan { font-size: 13px !important; } .branch-label, .branch-label tspan, text.branchLabel, .label tspan { font-size: 14px !important; }'} }%%
gitGraph
commit id: "scaffold"
branch feat/invoice-status
commit id: "wip"
checkout main
merge feat/invoice-status id: "Add status filter"
branch fix/csv-encoding
commit id: "wip "
checkout main
merge fix/csv-encoding id: "Fix CSV encoding" Trunk-based: one mainline, short-lived feature branches that squash-merge back as one commit each. Releases are deploys of main, with no develop, release, or hotfix lanes.
Branch names and commit messages: convention, not enforcement
Section titled “Branch names and commit messages: convention, not enforcement”Nothing technical hangs on either of these: Git does not care what you name a branch or how you phrase a commit.
They are conventions for human readers, the teammate reviewing your pull request and the engineer running git log a year from now.
Name a branch with a prefix for the kind of work, then a kebab-case description, optionally a ticket ID: feat/invoice-status, fix/csv-export-encoding, chore/bump-deps, refactor/extract-invoice-form, docs/api-readme.
With a ticket it becomes feat/INV-412-status-filter.
The prefix lets anyone scanning a branch list see what each one is for.
You can enforce the shape with a hook or repository rule, but the experienced call is to skip that and just agree on it.
A commit message has three parts. Write the subject in imperative mood , “Add status filter,” not “Added status filter,” as if completing “This commit will…”. Keep it under about 72 characters with no trailing period. If the change needs explanation, add a blank line and a body that says why, not what: the diff already shows what changed but not the reasoning.
Add status filter to invoices list
The list got unusable past ~50 invoices; users asked to narrow bystatus. Filters client-side for now — server-side filtering landswhen pagination does.Imperative subject, then the why. The reviewer gets the change and the reasoning in one read, and the body outlives the pull request in git log.
fixed stuffTells the next person nothing. Past tense, no subject discipline, no reasoning, and useless six months on.
You may also hear about Conventional Commits, a stricter convention that puts a machine-readable type at the front of every subject (feat:, fix:, chore:, and so on).
It earns its weight only when something consumes that structure: automated changelog generation, or version bumps for a published package.
An internal web app that ships no public package has no such consumer, so skip it, write good messages, and revisit if the team later adopts changelog tooling.
.gitignore, .gitattributes, and the .env rule
Section titled “.gitignore, .gitattributes, and the .env rule”The scaffold from early in the course already shipped two repository files that do important work. Your job isn’t writing them from scratch, it’s knowing what’s in them and why.
.gitignore lists path patterns Git refuses to track, keeping noise out of your repository: node_modules/ (reinstallable, enormous), .next/ (build output), *.log, coverage reports, OS junk like .DS_Store, and, critically, your environment files .env*, with a single exception for .env.example.
.gitattributes sets per-path behaviors.
The line that matters is * text=auto eol=lf, which normalizes line endings so a teammate on Windows can’t commit \r\n into a repository that deploys to Linux, where it would surface as spurious whole-file diffs and the occasional broken shell script.
node_modules/.next/*.logcoverage/.DS_Store.env*!.env.example* text=auto eol=lfThe !.env.example line is what prevents this.
It keeps .env.local (your real secrets) untracked while letting you commit .env.example (the same keys with placeholder values) so teammates know what to fill in.
The gitignore line is the prevention; rotation is the cure when it fails.
A full rotation playbook lives in the security hardening chapter.
Set these five defaults once
Section titled “Set these five defaults once”Most Git settings that smooth out team work are global: set them on your machine once and every repository you clone inherits them. Five are worth setting today.
git config --global init.defaultBranch maingit config --global pull.rebase truegit config --global push.autoSetupRemote truegit config --global rebase.autoStash truegit config --global rerere.enabled trueLine by line:
init.defaultBranch main: new repositories you create start onmaininstead of the historicalmaster.pull.rebase true: a plaingit pullfetches then merges, so on a branch with local commits it manufactures a stray “Merge branch ‘main’ into …” commit every time you sync; this makespullfetch then rebase instead, keeping the branch linear.push.autoSetupRemote true: the firstgit pushon a new branch sets its upstream automatically, so you drop the-u origin <branch>dance and just typegit push.rebase.autoStash true: stashes uncommitted changes before a rebase and restores them after, sogit pull --rebaseworks even with a dirty working tree.rerere.enabled true: short for rerere . Git records how you resolved a conflict and replays that resolution if the identical conflict reappears, which happens constantly when you rebase a long-lived branch repeatedly. The next lesson leans on it.
Never --force, always --force-with-lease
Section titled “Never --force, always --force-with-lease”After you rebase a branch, its history no longer matches the remote: the commits have new hashes.
A normal git push is then rejected, because Git sees commits on the remote your local branch doesn’t recognize and refuses to overwrite them.
To push a rebased branch, you have to force.
There are two ways, and the difference between them is the difference between a non-event and silently deleting a teammate’s work.
git push --forceOverwrites the remote unconditionally. If a teammate pushed to this branch since you last fetched, --force destroys their commits, with no check and no warning.
git push --force-with-leaseOverwrites only if the remote hasn’t moved. Git first checks that the remote branch still points where you last saw it; if a teammate pushed in between, the push aborts instead of clobbering their work.
So the reflex is simple and absolute: never --force, always --force-with-lease.
On your own feature branch the risk is usually low, but the habit costs nothing and saves a teammate’s afternoon the one day it matters.
Terminal or GUI
Section titled “Terminal or GUI”You don’t have to run Git from the terminal. Every command you’ve learned maps onto a button in VS Code’s source-control panel, GitHub Desktop, GitKraken, and others. The common split is to stay in the terminal for most work, where it’s fast and identical on every machine, and reach for a GUI for the two tasks where a visual surface wins: staging changes line by line, and resolving merge conflicts in a three-pane view instead of by hand. What you must understand is the commands; the interface on top of them is interchangeable.
Two quick notes. This course uses GitHub, but the concepts carry over to GitLab and Bitbucket. Signed commits, which cryptographically prove who authored a commit, belong in sensitive enterprise repositories, not a minimum setup.
Check your understanding
Section titled “Check your understanding”This lesson rests on one mental model and two reflexes.
The model: local history is messy and yours, while main’s is clean and shared.
The reflexes: rebase to sync, and lease not force.
First, the sync reflex — rebase your branch onto the latest main.
Your feat/invoice-status branch has fallen behind main while it sat in review. You’re about to merge, but first you need main’s newer commits in your branch. Which command catches your branch up the way the team default wants?
git merge maingit pull --rebase origin maingit pull origin maingit push --forcegit pull --rebase origin main fetches main’s new commits and replays yours on top, so your branch stays a straight line built on the latest main. git merge main and a plain git pull origin main both tie in a “Merge branch ‘main’ into…” commit, the clutter this workflow avoids. git push --force integrates nothing from main, and force-pushing here would be destructive.Finally, the push-safety reflex.
After rebasing your branch, a normal git push is rejected and you have to force. Why is --force-with-lease the reflex instead of plain --force?
--force.--force skips, giving you one last chance to back out.--force on protected branches, so --force-with-lease is the only flag that gets through.--force overwrites the remote unconditionally, so if a teammate pushed in between, their commit is gone with no warning. --force-with-lease first checks the remote branch still points where you last saw it, and refuses if it moved. It isn’t faster, shows no prompt, and has nothing to do with branch protection: its whole job is turning a silent clobber into a safe abort.External resources
Section titled “External resources”If you want to go deeper on the mechanics behind the workflow, these are the references worth your time, including one you can practise in.
The interactive sandbox from this lesson, in full — work through every level and watch the commit graph move under your own commands.
The canonical reference site for the exact workflow this lesson teaches — one trunk, short-lived branches, no develop or release lanes.
The official Git book's chapter on rebasing — the authoritative explanation of what replaying commits actually does.
GitHub's reference on squash vs. merge vs. rebase merging — the three buttons and what each does to history.