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 clean.
You have typed git add, git commit, and git push since the very beginning 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 touching a repository, that’s all they need to be. The history can be a swamp of “wip”, “fix”, and “ok now it works”, and nobody is hurt, because you’re the only one reading it.
The moment a second person commits to the same repository, the way you use Git stops being bookkeeping and starts deciding things. It decides whether merging to main means “this is deployable” or “this might be integrated, who knows.” It decides what a reviewer has to wade through to approve your change. It decides whether the automated checks you’ll add next chapter can actually block a bad merge or just decorate it. None of that depends on new commands; it depends on 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 configuration lines once and never think about them again, and understand why main’s history should read like a changelog. The rest of the chapter builds from there: the next lesson covers the rescue tools for when something goes wrong, the one after treats the pull request as a reviewable artifact, and the last makes the team’s rules mechanical instead of merely agreed.
The four objects you’ve been using all along
Section titled “The four objects you’ve been using all along”Before the workflow, four words. You already touch all four every day, so the goal here isn’t to teach them. It’s to pin down what each one is structurally, because every decision later in the lesson leans on these definitions.
A commit is a snapshot of your entire project at a point in time, stamped with an author, a message, and a pointer to its parent. The word that trips people up is snapshot. It’s tempting to picture a commit as a diff, a list of the lines that changed, but that’s not what Git stores. Git stores the full tree of files as it stood, and computes the diff when you ask to see one. This sounds like a pedantic distinction until later in the lesson, when you move commits around: the fact that each one is a complete, self-contained snapshot is exactly what makes that safe.
A branch is a movable pointer to one commit. That’s the whole thing. When you create a branch, Git does not copy your files, duplicate your history, or do any heavy work. It writes a single line into a small file: the 40-character hash of the commit you’re pointing at. Making a branch is as cheap as making a bookmark. This is why the workflow you’re about to learn works at all. If branches were expensive, you’d hoard them and keep them around for weeks. Because they cost nothing, you can treat them as disposable scratchpads: create one for a day’s work, collapse it onto the mainline, throw it away.
The staging area , also called the index , is the set of changes that will go into your next commit. When you run git add, you’re not saving anything; you’re choosing what the next commit will contain. Most people treat this as a redundant rubber stamp before git commit, an extra keystroke between them and saving. It is not. It’s a slicing tool, and it has its own section below, because using it well is one of the clearest signs of someone who understands Git.
A remote is a named URL pointing at a hosted copy of the repository, conventionally named origin and, for this course, living on GitHub. Your local repository and the remote are separate repositories that happen to share history; push and fetch are how commits travel between them. Nothing you do locally is visible to anyone until you push, and nothing a teammate does 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. Creating the branch copied nothing; it just wrote one commit hash into a small file. The remote on GitHub mirrors this same shape, and push and fetch move commits between the two.
Two more terms you’ll see used without further fanfare. Your working tree is the files as they sit on disk right now, including edits you haven’t staged. HEAD is the pointer to wherever you currently are, the commit your next commit will attach to. With those objects named, the workflow has vocabulary to stand on.
The everyday loop
Section titled “The everyday loop”Here is the full cycle, start to finish, for shipping one change on a team. Read it once now even though a couple of steps won’t be fully justified yet. The rest of the lesson is mostly the why behind these steps, and it helps to have the skeleton in your head first. Throughout the lesson we’ll follow one running example: 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 it waited for review,
mainmoved 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.
That’s the loop you’ll run dozens of times a week on a real team. The single 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 be in your working tree at the same time.
Opening and reviewing that pull request is a craft of its own, covering the description, what reviewers look for, and how the back-and-forth works, and it’s the subject of a later lesson in this chapter. For now, treat step 5 as a black box: a button on GitHub that proposes merging your branch into main. The interesting parts of this lesson are steps 3, 6, and 7, so let’s take them in turn.
The staging area is a slicing tool
Section titled “The staging area is a slicing tool”Start with step 3. You probably learned git add . to stage everything and git add path/to/file to stage one file. Both are coarse. The tool that earns the staging area its place is git add -p, where -p is for patch.
The situation it solves comes up constantly. You sit down to add the status filter, and while you’re in there you spot a date that’s formatting wrong two functions up. You fix it, because you’re already looking at it. Now your working tree holds two unrelated changes in the same neighborhood, maybe even the same file. If you git add . and commit, they land together: one commit, two stories, and a reviewer who now has to untangle whether the date fix is part of the filter feature or not. If the filter later needs to be 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 , each contiguous block of changed lines, and asks whether to stage it. You say yes to the hunks that belong to the filter, no to the date fix, and commit. The filter is now one clean commit. The date fix is still sitting in your working tree, ready to become its own commit or even its own 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 are sitting 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 unstaged in your working tree, ready to become 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 actual filter work. Answer y to stage it. Only this slice is now 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, nothing else. The date fix is untouched, still waiting in your working tree.
This is what people mean when they say a commit should be “one logical change.” It isn’t a rule handed down from on high; it’s something the staging area makes possible, and git add -p is the tool that makes it easy. Once it’s a habit, you stop thinking of staging as ceremony and start thinking of it as the moment you decide what story each commit tells.
Rebase vs. merge, made visual
Section titled “Rebase vs. merge, made visual”Now step 6, the part where beginner intuition reliably breaks. While your branch sat in review, teammates merged their own work, so main has moved forward and your branch is built on an older version of it. Your branch has fallen behind. Before merging, you need to bring main’s new commits into your branch and resolve any conflicts. There are two ways to do that, and they produce very different shapes of history.
The first is merge. git merge main, run from your branch, takes the two diverged lines of history and ties them together with a new commit, a merge commit, that has two parents, one from each side. Nothing is moved; 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 to sit on top of main’s latest commit, and then 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, which explains everything in the rest of this lesson, is that replayed commits are brand new commits. Same changes, same messages, but new parents and therefore new hashes. Rebasing doesn’t move your commits; it makes copies of them in a new place and abandons the originals. Watch the motion in the sequence below.
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, the merge option. 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, the rebase option. 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, since both have main’s changes integrated with yours. The difference is the shape they leave behind, and shape is what you’ll optimize for in the next section. For now, the takeaway is just the picture: merge forks and rejoins; rebase stays in a line.
Reading about rebase is not the same as doing one. Git is muscle memory, and the way “the graph stays linear” actually sticks is by watching the graph linearize under your own command. The sandbox below is a real Git environment running in your browser, set up with exactly the divergence from the sequence above: a feature branch built 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, so the tree goes from a fork to a straight line.
One more term while it’s in front of you. When main hasn’t moved since you branched, there’s nothing to replay and nothing to tie together: your commits already sit directly on top of main’s tip. Integrating in that case is a fast-forward , where Git just slides main’s pointer forward to your commits. No merge commit, no rebase, nothing. It’s the cleanest case, and it’s what the workflow you’re about to learn arranges for on main almost every time.
The 2026 team default: rebase locally, squash-merge on the PR
Section titled “The 2026 team default: rebase locally, squash-merge on the PR”This is the core idea of the entire lesson. If you remember one thing, remember this rule and the picture that justifies it:
Take the two halves in turn.
Rebase locally is step 6 of the loop, and you now know what it does to the graph. 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. The alternative, a plain git pull, would merge main into your branch, scattering “Merge branch ‘main’ into feat/invoice-status” commits through your history every single time you sync. Rebase keeps your branch clean while you work on it.
Squash-merge on the pull request is step 7, and it’s where the cleanup happens. While you were working, your branch accumulated honest, messy commits: “wip”, “actually fix the filter”, “address review comments”, “fix typo”. That mess is fine; it’s your branch, it’s a scratchpad, nobody is grading it. But you don’t want that mess on main. GitHub’s “Squash and merge” button takes your entire pull request, however many commits it contains, and collapses it into a single commit on main, with a message you write. The internal noise disappears. What lands is one commit that says “Add status filter to invoices list,” and that’s the only trace of your branch that 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" What your branch actually looks like: honest, messy, and yours. The “wip” and “fix typo” commits, and even the stray Merge branch main from a careless git pull (no --rebase), are all noise. Nobody grades this.
%%{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" What lands on main: one commit per shipped change. The entire branch above collapsed into a single commit, and the internal noise never crosses over.
Here is the payoff, and why this is the default rather than one option among several. When main’s history is one commit per shipped change, every line of git log reads like a changelog entry, a scannable record of what shipped and when. Every commit on main is a complete, deployable change, so there’s never a broken intermediate state sitting in the mainline between two halves of a feature. It also pays off the day something breaks. The tools you’ll meet next lesson for finding which change introduced a bug, and for undoing a change cleanly, both work on whole commits, so they land 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 every recovery tool in the next lesson actually usable.
When does the other option, a real merge commit 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 itself a meaningful, self-contained step you genuinely want recorded separately on main: “rename the type,” then “move the file,” then “update the call sites,” each one a clean checkpoint. That’s the exception. In normal feature work, where your branch’s commits are scratchpad noise, the default is squash, every time.
Make it automatic with git pull --rebase
Section titled “Make it automatic with git pull --rebase”There’s a sharp edge hiding in step 6 that’s worth handling before you ever hit it. By default, git pull is a fetch followed by a merge. So every time you sync a branch that has local commits, a plain git pull manufactures one of those “Merge branch ‘main’ into feat/invoice-status” commits, the exact clutter the last section warned about, except now it’s automatic and multiplied by every sync you do all day.
The fix is one line, set once, globally:
git config --global pull.rebase trueWith that set, git pull becomes fetch-then-rebase everywhere. Your local commits replay on top of whatever you pulled, your branch stays linear, and you never manufacture a stray merge commit again. This is the single most valuable Git setting most developers never flip: it turns the team default from something you have to remember into something that happens automatically.
There’s a natural worry: what if you have uncommitted changes when you pull? Rebase needs a clean working tree to replay onto. The companion setting handles it:
git config --global rebase.autoStash trueThat tells Git, before a rebase, to automatically tuck away your uncommitted changes, do the rebase, then put them back, so git pull --rebase just works even with a dirty working tree. We’ll gather both of these (and three more) into one copy-paste block near the end. For now, know that the team default is one config line away from being effortless.
The trunk-based workflow (and why not Git Flow)
Section titled “The trunk-based workflow (and why not Git Flow)”Step back and look at the loop as a whole, because it has a name. Everything you’ve done, 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. There is exactly one long-lived branch, the trunk , which is main. Every other branch is short-lived and branches off main. There is no develop branch, no release/* branches, no hotfix/* branches. Releasing isn’t a separate ceremony on a separate branch; releasing is 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.”
If you read older Git tutorials, you will run into 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. This course doesn’t take historical detours, but Git Flow is worth naming once, so that when you meet it in an old blog post you recognize it as a tool from a different era rather than the way things are done. Git Flow was designed 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 those branches solved is solved better somewhere else. A preview deployment per pull request (covered in the chapter after CI) gives you a live, testable URL for every change. Automated checks per pull request (the next chapter) gate quality on every merge. Feature flags let you merge risky code to main switched off. With those in place, develop, release, and hotfix are pure overhead: three extra long-lived branches to keep in sync for no benefit. Named, understood, set aside.
%%{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 long-lived 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 a single 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”Two habits to adopt, and the framing matters as much as the habits: nothing technical hangs on either of these. Git does not care what you name a branch or how you phrase a commit. These are conventions for human readability, for the teammate reviewing your pull request and the future engineer running git log.
For branch names, prefix with the kind of work, then a kebab-case description, optionally with a ticket ID: feat/invoice-status, fix/csv-export-encoding, chore/bump-deps, refactor/extract-invoice-form, docs/api-readme. With a ticket, that becomes feat/INV-412-status-filter. The prefix lets anyone scan a list of branches and know at a glance what each is. You can enforce the shape with a hook or a repository rule, but the experienced call is almost always to skip that and just agree on it.
For commit messages, three things. Write the subject in imperative mood , “Add status filter,” not “Added status filter,” as if completing the sentence “This commit will…”. Keep the subject under about 72 characters with no trailing period. And if the change needs explanation, add a blank line and a body that explains why, not what: the diff already shows what changed, but it can’t show the reasoning. Compare the two below.
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. A reviewer understands the change and the reasoning in one read, and the body survives in git log long after the pull request is forgotten.
fixed stuffTells the next person nothing. Past tense, no subject discipline, no reasoning. Technically a valid commit, and useless six months from now.
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’s a fine convention, but it only earns its weight when something consumes that structure: automated changelog generation, or automatic version-number bumping for a published package. For an internal SaaS app that doesn’t ship a public package, it’s structure with no consumer, so skip it and just write good messages. If the team later adopts changelog tooling, revisit it then. Named, deferred to the team.
.gitignore, .gitattributes, and the .env rule
Section titled “.gitignore, .gitattributes, and the .env rule”Your project already has two repository files that quietly do important work; the scaffold from early in the course shipped them. The experienced move isn’t authoring these from scratch. It’s knowing what’s in them and why.
.gitignore lists path patterns Git refuses to track. It keeps the noise out of your repository: node_modules/ (reinstallable, enormous), .next/ (build output), *.log, coverage reports, OS junk like .DS_Store, and, critically, .env*, your environment files, with a single exception for .env.example.
.gitattributes sets per-path behaviors. The line that matters most is * text=auto eol=lf, which normalizes line endings. It ensures a teammate on Windows doesn’t commit \r\n line endings into a repository that deploys to Linux, which would otherwise show up as spurious whole-file diffs and the occasional broken shell script.
node_modules/.next/*.logcoverage/.DS_Store.env*!.env.example* text=auto eol=lfThere’s one part of this section that beginners get badly wrong, so it gets a callout.
That !.env.example line in .gitignore is the prevention. 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 prevention is the gitignore line; the cure, when prevention fails, is rotation, and there’s a full rotation playbook earlier in the course, in the security hardening chapter. There’s also a way to scrub a secret out of history retroactively. It’s a blunt, last-resort tool, and it gets a one-line mention in the next lesson, but rotation comes first regardless, because the secret was already exposed the moment it hit the remote.
Set these five defaults once
Section titled “Set these five defaults once”Most of the Git settings that smooth out team work are global: set them on your machine once and every repository you ever clone inherits them. Here are the five 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: the everyday hygiene from earlier, wheregit pullrebases instead of merging, so you never manufacture stray merge commits.push.autoSetupRemote true: the firstgit pushon a new branch automatically sets its upstream, so you can drop the-u origin <branch>dance and just typegit push.rebase.autoStash true:git pull --rebaseworks even with a dirty working tree, by stashing and restoring your changes around the rebase.rerere.enabled true: short for rerere , “reuse recorded resolution.” Git remembers how you resolved a given conflict and replays that resolution automatically if the identical conflict shows up again, which it does constantly when you rebase a long-lived branch repeatedly. You’ll feel this one pay off in the next lesson.
Set these and forget them. The payoff is lifelong and the cost is thirty seconds.
Never --force, always --force-with-lease
Section titled “Never --force, always --force-with-lease”Back to step 6 one last time, for the one push-safety reflex this lesson has to leave you with. After you rebase your branch, its history no longer matches what’s on the remote, because you rewrote it and the commits have new hashes. So a normal git push is rejected: Git sees the remote has commits your local branch doesn’t recognize and refuses to overwrite them. To push a rebased branch, you have to force.
There are two ways to force, 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 silently 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.
--force overwrites the remote unconditionally. If a teammate happened to push a commit to your branch since you last fetched, which is rare on a personal feature branch but not impossible, --force erases it, no questions asked, with no way to know it happened. --force-with-lease adds one safety check: it confirms the remote branch is still where you last saw it, and if it isn’t, because someone pushed in between, it aborts the push instead of destroying their commit. The reflex is simple and absolute: never --force, always --force-with-lease. On your own feature branch the practical risk is usually low, but the habit costs you nothing and saves a teammate’s afternoon the one day it actually matters.
The Git GUI question
Section titled “The Git GUI question”A fair question at this point: do you have to do all of this in the terminal? No. VS Code’s source-control panel, GitHub Desktop, GitKraken, Lazygit, and Tower are all legitimate, and the commands you’ve learned map directly onto buttons in every one of them. There’s no virtue in the terminal for its own sake. The experienced split is to use the terminal for almost everything, because it’s fast, scriptable, and identical on every machine, and to reach for a GUI for the two things where a visual surface is genuinely faster. Those two things are line-by-line staging, where VS Code’s diff editor is a nicer git add -p, and resolving merge conflicts, where a three-pane visual merge beats editing conflict markers by hand. The commands are what you must understand; the interface on top of them is interchangeable.
Two related notes, equally brief. This course teaches GitHub, but the concepts port directly to GitLab or Bitbucket if your team uses those. And signed commits, which cryptographically prove who authored a commit, are a reasonable posture for sensitive enterprise repositories but aren’t part of a minimum-viable setup, so we name them and move on.
Check your understanding
Section titled “Check your understanding”The whole lesson rests on one mental model, that local history is messy and yours while main’s history is clean and shared, and on two reflexes: rebase to sync, and lease not force. The checks below probe exactly those.
First, the mental model. Each item below either lives on your scratchpad feature branch, where it’s allowed to be messy and you can rewrite it freely, or it lives on main, where history is clean and shared and nobody rewrites it. Sort them.
Each item below belongs to one of two kinds of history. Sort them. Drag each item into the bucket it belongs to, then press Check.
wip commitfix typo commitgit pullNext, the sync reflex.
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 is the team default. It fetches main’s new commits and replays your branch’s commits on top of them, so your branch stays a straight line built on the latest main. git merge main and a plain git pull origin main (no --rebase) both tie in a “Merge branch ‘main’ into…” commit — exactly the clutter the workflow avoids. git push --force doesn’t integrate main’s changes at all, and force-pushing in its place 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 — 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.