Git & GitHub

Rebase, Clean History and Team Workflows

Rebase versus merge, tidying history, force-with-lease, branch protection, CODEOWNERS and a first GitHub Actions workflow.

Lesson 0511 min read#git#rebase#ci

On this page

  1. Words used in this lesson
  2. Rebase: a different way to join work
  3. Interactive rebase: clean up before you share
  4. Force push, safely
  5. Team workflows
  6. Protect the main branch (GitHub)
  7. CODEOWNERS
  8. A GitHub Actions workflow, line by line
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: rebase replays your commits on a new base to give a straight history, interactive rebase cleans up your own messy commits before review, and team rules (branch protection, CODEOWNERS and automatic checks) keep the shared branch safe. The golden rule: rewrite only history that nobody else has.

Lesson 2 joined branches with merge. This lesson shows the other tool, rebase, and why teams choose between them. Then we clean a branch with an interactive rebase (run here without an editor), push it safely with --force-with-lease, and look at the team side: which branching workflow to use, how to protect main on GitHub, how CODEOWNERS works, and a short GitHub Actions file explained line by line.

For Git questions in interviews (merge vs rebase, recovery, monorepos) see Full-Stack Lesson 24. For CI/CD from the deployment side, see DevOps Lesson 14 (CI/CD with GitHub Actions).

What I ran / checked. The Git commands ran with git version 2.43.0 on Linux in throwaway repositories. Force-push examples used a local bare repository as the remote, not GitHub. The interactive rebases were driven without an editor: GIT_SEQUENCE_EDITOR (the script that edits the to-do list) and GIT_EDITOR (the message editor) were set to small commands, which is how you can automate it. Not run: branch protection, rulesets, CODEOWNERS and the GitHub Actions workflow. I read them in github/docs (about-protected-branches.md, about-code-owners.md, about-rulesets.md, pull-request-merges.md, and the Actions quickstart). The workflow file is checked only as valid YAML with a parser, and I confirmed with git ls-remote that actions/checkout and actions/setup-node have a v5 tag. I read the "Branching Workflows" and "Rebasing" chapters of the Pro Git source (progit/progit2) and the --force-with-lease text in git-push.adoc. Trunk-based development and gitflow are described from general knowledge, not from a source I read.

Words used in this lesson

  • To rebase is to take your commits and replay them, one by one, on top of a different base commit. The replayed commits are new commits with new hashes.
  • Interactive rebase (git rebase -i) lets you edit the list of commits first: reorder, reword, squash, drop.
  • To squash is to merge several commits into one. To fixup is the same but the message of the later commit is thrown away.
  • A force push overwrites the remote branch even if it is not a fast-forward. --force-with-lease is a safer version that checks first.
  • Linear history means a straight line of commits with no merge commits.
  • Trunk-based development is a workflow where everyone merges small changes into one main branch ("trunk") very often.
  • Branch protection is a GitHub setting that adds rules to a branch, such as "needs a review" or "checks must pass".
  • CODEOWNERS is a file that says who owns which paths, so they get asked to review changes there.
  • CI (continuous integration) means a server runs tests on every change automatically.
  • A workflow (GitHub Actions) is a YAML file that describes what to run and when.

Rebase: a different way to join work

You have a feature branch and main moved on. A merge makes a diamond. A rebase moves your commits to start from the tip of main:

Shell
git log --oneline --graph --all     # beforegit rebase maingit log --oneline --graph --all     # after
TEXT
* d1ad6f4 Add f2* 2a69f59 Add f1| * b11888f Main change 1|/* c824ea5 Base
TEXT
Successfully rebased and updated refs/heads/feature.* c9d1fb7 Add f2* b0d50a6 Add f1* b11888f Main change 1* c824ea5 Base

Look at the hashes. Before: 2a69f59 and d1ad6f4. After: b0d50a6 and c9d1fb7. The content is the same, but the commits are new copies with a new parent (lesson 0: a different parent means a different hash). The old ones are not deleted; the reflog still lists them:

TEXT
c9d1fb7 HEAD@{0}: rebase (finish): returning to refs/heads/featurec9d1fb7 HEAD@{1}: rebase (pick): Add f2b0d50a6 HEAD@{2}: rebase (pick): Add f1b11888f HEAD@{3}: rebase (start): checkout main

Now merging feature into main is a fast-forward, and the history is a straight line:

Shell
git switch maingit merge --ff-only feature
TEXT
Updating b11888f..c9d1fb7Fast-forward

Merge or rebase?

MergeRebase
HistoryKeeps the true shape, with diamondsStraight line
CommitsUnchangedCopied (new hashes)
ConflictsResolved once, in the merge commitMay need resolving at each replayed commit
Safe on shared branchesYesNo
Good forJoining shared work, keeping a recordTidying your own branch before sharing, updating it from main

The Pro Git book states the rule in one line: do not rebase commits that exist outside your repository and that people may have based work on. If you rebase a branch that others already pulled, they have the old commits and you have the new ones, and the next pull merges both versions and duplicates the work. Pro Git has a section called "The Perils of Rebasing" on this.

A conflict during a rebase

A rebase can conflict, as a merge can. It stops at the commit that fails:

TEXT
Auto-merging f.txtCONFLICT (content): Merge conflict in f.txterror: could not apply e76c54c... Topic edithint: Resolve all conflicts manually, mark them as resolved withhint: "git add/rm <conflicted_files>", then run "git rebase --continue".hint: You can instead skip this commit: run "git rebase --skip".hint: To abort and get back to the state before "git rebase", run "git rebase --abort".

The file shows markers like a merge, but notice the sides:

TEXT
<<<<<<< HEADmain=======topic>>>>>>> e76c54c (Topic edit)

HEAD (our side) is main, the base we are replaying onto. The other side is my own commit being replayed. (Lesson 2, Q10: "ours" and "theirs" flip in a rebase.) I wrote resolved in the file, ran git add f.txt, then git rebase --continue:

TEXT
Successfully rebased and updated refs/heads/topic.* adf862a Topic edit* 948a742 Main edit* d766836 Base

Escape hatch: git rebase --abort returns to the state before the rebase. Because the old commits are in the reflog, a bad rebase can always be undone: git reset --hard ORIG_HEAD right after, or pick a hash from git reflog.

Interactive rebase: clean up before you share

My branch has four commits that I made while working. Only the last one deserves to be in the history:

TEXT
007b4e0 Add search box0ed76a1 more wip353bd1a fix typoc224eed WIP
Shell
git rebase -i main

Git opens your editor with a to-do list (oldest first). To show it without an editor, I used a tiny script as GIT_SEQUENCE_EDITOR that prints the file. The list Git gave:

TEXT
pick c224eed WIPpick 353bd1a fix typopick 0ed76a1 more wippick 007b4e0 Add search box# Rebase c824ea5..007b4e0 onto c824ea5 (4 commands)## Commands:# p, pick <commit> = use commit# r, reword <commit> = use commit, but edit the commit message# e, edit <commit> = use commit, but stop for amending# s, squash <commit> = use commit, but meld into previous commit# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous#                    commit's log message, unless -C is used, in which case#                    keep only this commit's message; -c is same as -C but#                    opens the editor# x, exec <command> = run command (the rest of the line) using shell# b, break = stop here (continue rebase later with 'git rebase --continue')# d, drop <commit> = remove commit

(I show the part of the list that Git printed; the real file has a few more comment lines.) You edit the word at the start of each line. pick keeps a commit. squash melds it into the one above. reword stops so you can change the message. drop removes it.

Squash four commits into one

I changed lines 2 to 4 from pick to squash, using sed as the sequence editor, and gave the final message through a script as GIT_EDITOR:

Shell
GIT_SEQUENCE_EDITOR="sed -i -e 2,4s/^pick/squash/" \GIT_EDITOR="sh msg.sh" git rebase -i maingit log --onelinegit log -1 --format=%B
TEXT
Successfully rebased and updated refs/heads/work.b116992 Add search boxc824ea5 BaseAdd search boxCollects the query and shows matching results.

Four commits became one (b116992). The msg.sh script just wrote the message Add search box and a body line into the file Git gave it. In a real terminal you type this in the editor.

Fixup and reword

Another common pattern. After a review comment about an earlier commit, make a fixup commit that points at the commit to fix, then let Git fold it in:

Shell
git commit --fixup HEAD~1          # message becomes: fixup! Fix bugGIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
TEXT
Before:                    After:94bc0c7 fixup! Fix bug     f1bcd57 Add y70513b6 Add y              496b63a Fix bug      <- now contains the fix8807be5 Fix bug            b116992 Add search box

(In the run the hashes were as in the lists; I wrote the two columns side by side by hand to compare.) --autosquash moves each fixup! commit under its target and marks it fixup. Setting GIT_SEQUENCE_EDITOR=true accepts the list without opening anything. In real life you just save and close the editor. You can turn it on for good with git config rebase.autoSquash true.

To change one message, change pick to reword for that line:

Shell
GIT_SEQUENCE_EDITOR="sed -i -e 2s/^pick/reword/" GIT_EDITOR="sh reword.sh" git rebase -i maingit log --oneline
TEXT
3eacead Add y4994df2 Fix crash on empty searchb116992 Add search boxc824ea5 Base

All the commits above the reworded one got new hashes, too (Add y changed). This is lesson 0 again: change one commit and every commit after it changes.

Interactive rebase is a history editor. Use it on your own branch before it is reviewed or merged. Make a backup first with git branch backup and remember git rebase --abort while it is running.

Force push, safely

After you rebase a branch that you had already pushed (for your own pull request), the remote branch has the old commits. A normal push is rejected:

TEXT
 ! [rejected]        feature -> feature (non-fast-forward)hint: Updates were rejected because the tip of your current branch is behindhint: its remote counterpart.

You must overwrite. git push --force does it blindly. If a teammate pushed to the branch in the meantime, --force silently deletes their commit. --force-with-lease is the safer option. The Git manual says it overrides the restriction "if the current value of the remote ref is the expected value", like taking a lease on the ref. By default, "expected" means what your remote-tracking branch (origin/feature) shows.

Shell
git push --force-with-lease
TEXT
 + 2a69f59...6c27fc0 feature -> feature (forced update)

That worked, because nobody had touched the branch. Now my colleague Bob pushed a commit to feature, and I rewrote my local commit and tried again:

TEXT
 ! [rejected]        feature -> feature (stale info)

Stale info. Git refused because the remote had a commit I had never seen. Exactly what I wanted. But look at the next step. I ran git fetch and then the same command again:

TEXT
 + cf4044f...0a9ace2 feature -> feature (forced update)

It went through, and it overwrote Bob's commit. After a fetch, my origin/feature matched the server, so the "lease" was valid again, although I never looked at Bob's work. The lesson: --force-with-lease protects you only from commits you have not fetched. Do not run git fetch and force push in a reflex (some editors and tools fetch in the background, too). After a "stale info" rejection, look at git log origin/feature, and integrate what is there. You can also be exact: --force-with-lease=feature:<hash> says "overwrite only if the remote is at this hash". The same Git manual text warns that blind --force loses others' work.

Good habits: force push only your own feature branches, never main, and prefer --force-with-lease to --force. Protected branches block force pushes anyway (below).

Make pull easier

Two settings that many people turn on:

Shell
git config pull.rebase true         # pull = fetch + rebase, not mergegit config rebase.autoStash true    # stash local edits around a rebase

Team workflows

There is no single right answer. These three are common. (Pro Git, "Branching Workflows", describes the long-running and topic branch ideas. The names and the comparison below are general knowledge, not from a single source.)

Trunk-basedFeature branches (GitHub flow)Gitflow
BranchesOne main; branches live hoursmain plus a branch per feature, short or mediummain, develop, feature/*, release/*, hotfix/*
Merge to mainMany times a dayAfter PR reviewdevelop to release to main
Incomplete workHidden with feature flagsStays on the branchStays on the feature branch
ReleaseStraight from mainFrom main after mergeFrom release branches
NeedsGood tests, fast CI, flagsPR review and CIDiscipline; slower
Best forWeb apps with continuous deploymentMost teamsVersioned products (apps, libraries) with several supported releases

How to choose:

  • Deploy several times a week and have good tests? Trunk-based or short feature branches. The shorter a branch lives, the fewer conflicts and the less risk.
  • Ship versions that customers install and support old releases? Release branches (a lighter form of gitflow) make sense.
  • Avoid long-lived feature branches. They drift away from main, and the merge becomes a project of its own (lesson 2).

Whatever you pick, write it down in the README or CONTRIBUTING.md: how to name branches, how big a PR should be, and which merge method to use (lesson 3). Merge methods tie in: squash and merge on GitHub gives a linear main with one commit per PR. The GitHub docs also list a "Require linear history" option that blocks merge commits.

Protect the main branch (GitHub)

Not run; from the GitHub docs. A branch protection rule adds checks that cannot be skipped by accident. The docs list these settings:

SettingWhat it does
Require pull request reviews before mergingChanges reach the branch only through a PR with the set number of approvals
Dismiss stale approvalsNew commits that change the diff remove earlier approvals
Require review from code ownersOwners listed in CODEOWNERS must approve changes to their files
Require status checks before mergingNamed CI checks must pass; "strict" also requires the branch to be up to date with the base
Require conversation resolutionAll review comments must be resolved
Require signed commitsOnly verified signed commits are accepted
Require linear historyNo merge commits; use squash or rebase merge
Restrict who can pushOnly chosen people or teams can push
Allow force pushes / deletionsBoth are off by default on protected branches

When someone breaks a rule, the push is refused with a message like the docs' example remote: error: GH006: Protected branch update failed for refs/heads/main. By default, the rules do not apply to repository admins unless you enable "do not allow bypassing the above settings". The docs also describe rulesets: a newer way to define rules for many branches or tags, even across repositories in an organization, with a bypass list. Several rulesets can apply at the same time, while only one branch protection rule applies to a branch.

A sensible start for a small team: require a PR with one approval, require the CI check to pass, require conversations resolved, block force pushes. Add code owners when the team grows.

CODEOWNERS

A file named CODEOWNERS in .github/, the repository root or docs/ says who is responsible for which paths. The docs say: owners are automatically requested for review when a PR changes their files, and (with the branch protection setting) their approval can be required. Syntax follows .gitignore rules, with a pattern then one or more owners on the same line; the last matching pattern wins.

TEXT
# Default owners for everything*              @acme/platform-team# Frontend code/web/          @acme/frontend# Database migrations need the data team/db/migrations/  @acme/data-team @maria# CI and release files/.github/      @acme/platform-team

Rules from the docs worth remembering: owners need write access to the repository (teams must be visible and have write access), the file is read from the base branch of the PR, it must be under 3 MB, and lines with invalid syntax are skipped. Do not use ! negation or [ ] ranges; they do not work in CODEOWNERS. CODEOWNERS only asks for review by itself. To make it a rule, switch on "Require review from Code Owners" in the protection rule. Drafts do not request owners until marked ready.

A GitHub Actions workflow, line by line

CI means a server runs your checks on every change, so a failing test blocks the merge instead of reaching main. In GitHub Actions the instructions live in a YAML file under .github/workflows/. The GitHub quickstart says the folder name is required, and the file can have any name ending in .yml or .yaml. This is a short but realistic file for a Node.js project. I did not run it (it needs GitHub). I checked only that it is valid YAML.

YAML
name: CIon:  pull_request:    branches: [main]  push:    branches: [main]permissions:  contents: readconcurrency:  group: ci-${{ github.ref }}  cancel-in-progress: truejobs:  test:    runs-on: ubuntu-latest    timeout-minutes: 10    steps:      - name: Check out the code        uses: actions/checkout@v5      - name: Set up Node.js        uses: actions/setup-node@v5        with:          node-version: 22          cache: npm      - name: Install dependencies        run: npm ci      - name: Lint        run: npm run lint      - name: Test        run: npm test
LineWhat it means
name: CIThe name shown in the Actions tab and in the checks list on a PR
on:The events that start the workflow
pull_request: branches: [main]Run when a PR targets main (this is the check that gates a merge)
push: branches: [main]Also run after a merge lands on main
permissions: contents: readGive the workflow's token only read access to the repository (least privilege)
concurrency: group: ...Only one run per branch at a time; ${{ github.ref }} is the branch name
cancel-in-progress: trueA new push cancels the older, now useless run
jobs:The list of jobs. Jobs run in parallel unless you chain them with needs:
test:The job id (your choice). Branch protection refers to the job name, so keep job names unique
runs-on: ubuntu-latestThe kind of machine (a GitHub-hosted Linux runner)
timeout-minutes: 10Stop a stuck job instead of waiting for the default limit
steps:The steps of the job, run in order on the same machine
uses: actions/checkout@v5Run a ready-made action that copies your repository onto the runner. @v5 pins a version tag
uses: actions/setup-node@v5Install Node.js
with: node-version: 22Inputs for the action: which Node version
cache: npmReuse downloaded npm packages between runs to save time
run: npm ciRun a shell command. npm ci installs exactly what the lock file says
run: npm run lint and npm testThe checks. A non-zero exit code fails the step, the job and the PR check

Tie it together: in branch protection, mark the test check as required. Now a PR cannot merge until lint and tests pass. In DevOps Lesson 14 you can see the same ideas extended to build and deploy steps (CI/CD with GitHub Actions).

Pinning actions by tag (@v5) trusts whoever controls that tag. Stricter teams pin a full commit hash. Never print secrets in logs, and give the workflow token the smallest permissions it needs.

Try it

  1. In a practice repository make main with one commit, then a feature branch with two commits, then add one more commit on main. Rebase feature on main. Compare the hashes of the two feature commits before and after.
  2. Make four small commits: WIP, typo, WIP 2, Add login form. Use git rebase -i to squash them into one commit called Add login form.
  3. Make a mistake in a rebase (squash the wrong commits). Undo it with the reflog.
  4. Create a bare repository as a remote. Push a branch, rebase it, and try to push. What happens? Then use --force-with-lease.
  5. Write a CODEOWNERS file so that /docs/ is owned by @acme/writers and everything else by @acme/devs. Which line must come first?
  6. In the workflow above, what would you change so the workflow also runs on pull requests to a release branch?
  7. Name two settings you would turn on first for main in a team of five, and say why.

Answers

  1. The two feature commits have new hashes after the rebase; the messages and content are the same. git reflog still lists the old ones.
  2. git rebase -i main (or HEAD~4), change pick to squash (or fixup) on the lines for the three small commits, save, and write the final message in the second editor.
  3. git reflog shows entries like rebase (start). Run git reset --hard HEAD@{N} for the line before rebase (start), or git reset --hard ORIG_HEAD immediately.
  4. The push is rejected as non-fast-forward. git push --force-with-lease succeeds if nobody else pushed to that branch.
  5. * @acme/devs first, then /docs/ @acme/writers. The last matching pattern wins, so the specific rule has to come after the general one.
  6. Change branches: [main] under pull_request to branches: [main, release].
  7. A good start: require a PR with at least one approval (so nothing is pushed straight to main), and require the CI check to pass (so broken code cannot merge). Blocking force pushes is already the default.

Interview questions

Merge or rebase? How do you decide? (Must know)

Merge keeps the real history and never rewrites commits, so it is safe on shared branches. Rebase makes a straight history by copying commits onto a new base, which gives them new hashes, so I only use it on branches that nobody else has. In practice I rebase my own feature branch onto main to keep it current, and then merge it by PR with the method my team chose (squash is common). I never rebase main or a branch others work on.

What is the golden rule of rebasing and why? (Must know)

Do not rebase commits that others already have. Rebase creates new commits. If someone based work on the old ones, their history now contains commits that no longer exist on the remote. The next pull brings back the old commits next to the new copies, and the result is duplicated changes and confusing conflicts. If it does happen, the fix is to coordinate: everyone rebases their work onto the new history.

How do you squash the last four commits into one? (Must know)

git rebase -i HEAD~4, change pick to squash (or fixup) on the last three lines, save, then write the combined message. Another way is git reset --soft HEAD~4 and a new git commit. Both give one new commit, so I do this before the branch is shared.

Follow-up: "What is the difference between squash and fixup?" Squash keeps the message of the squashed commit and lets me edit the combined message. Fixup discards its message and keeps the previous one.

What does --force-with-lease do and why is it better than --force? (Must know)

--force overwrites the remote branch whatever it contains. --force-with-lease overwrites only if the remote branch is still at the commit I last fetched. If a teammate pushed a new commit, my push is rejected with "stale info", so I don't delete their work. Its weak point: if I fetch and then force push without looking, the lease is valid again and their commit is overwritten. I tested exactly this. So I check git log origin/branch first. For critical cases I give the expected hash with --force-with-lease=branch:hash.

You force pushed over a teammate's work. What now? (Senior signal)

First, tell the team, and ask them to stop pushing to that branch. The lost commits still exist: in my reflog if I fetched them, and in the teammate's clone. The teammate can run git reflog, find their commit and push a branch from it. I can also look for the old tip hash in the push output (+ old...new). Then we put the commit back (cherry-pick or reset the branch) and agree on a rule: force push only your own branch, always with --force-with-lease, and protect shared branches so force pushes are blocked.

Trunk-based, GitHub flow or gitflow? Which would you choose? (Senior signal)

It depends on how we release. For a web app with continuous deployment I choose trunk-based development or short feature branches with small PRs, fast CI and feature flags for unfinished work. For software with versions customers install and several supported releases, I use release branches (a lighter gitflow). I avoid long-lived branches in all cases. Whatever the team chooses, I write it down so it is the same for everyone.

How do you protect the main branch on GitHub? (Must know)

With a branch protection rule or a ruleset. My starting set: require a pull request with at least one approval, require status checks to pass (the CI job), require conversation resolution, and keep force pushes and deletion off (that is the default for protected branches). Later I add code owner reviews and, if the team wants, linear history or signed commits. I also decide whether admins are allowed to bypass, because by default the rules do not apply to admins.

What is CODEOWNERS, and what does it not do? (Senior signal)

It maps paths to people or teams. When a PR changes those paths, the owners are requested for review automatically. By itself it does not block anything. To make it a rule, I enable "Require review from Code Owners" in the branch protection. Owners need write access, the file is read from the base branch of the PR, and the last matching pattern wins, so general rules come first and specific ones later.

Explain this workflow file. (Senior signal)

I would read it top to bottom. on lists the events (here PRs to main and pushes to main). permissions limits the token. concurrency cancels older runs of the same branch. jobs has one job test that runs on an Ubuntu runner. Its steps check out the code, set up Node with a cache, install with npm ci, then lint and test. If any step exits with an error, the job fails and the PR check turns red. I would add that the check must be marked required in branch protection, otherwise it only advises.

Why do some teams prefer a linear history, and what does it cost? (Trap)

A linear main is easy to read, easy to bisect and each commit can be reverted alone. Squash merge or rebase merge makes it. The cost is that you lose the original branch structure, and with squash you lose the small steps (they remain in the PR). Squash also needs short branches: if you keep working on the same branch after a squash merge, later PRs can re-include old commits and conflict. I would choose linear history when the team values readability and uses small PRs.

A rebase conflict shows "HEAD" and my commit swapped compared with a merge. Why? (Trap)

During a rebase Git checks out the new base, then replays my commits one by one onto it. So HEAD (ours) is the base I am replaying onto, and the other side is my own commit. In a merge it is the opposite. I just read the marker labels (the commit message on the >>>>>>> line helps), decide the final text, git add, and git rebase --continue.

Key takeaways

  • Rebase copies commits onto a new base: straight history, new hashes. Rewrite only history nobody else has.
  • git rebase -i lets you pick, reword, squash, fixup or drop commits. Use it to clean your own branch before review. --autosquash folds fixup! commits in.
  • After a rebase you must force push your own branch. Prefer --force-with-lease, and look before you force after a fetch.
  • Pick a workflow that fits how you release: trunk-based or short feature branches for continuous delivery; release branches when you support several versions. Keep branches short-lived.
  • Protect main: PR with review, required CI checks, resolved conversations, no force pushes. Add CODEOWNERS for review routing.
  • A GitHub Actions workflow is a YAML file under .github/workflows/ with events (on), jobs, a runner and steps. Mark its check as required so it blocks bad merges.
  • The reflog can undo almost any rebase mistake.

What's next

This is the last lesson of the series. To go further, read Full-Stack Lesson 24 for the interview view of Git and monorepos, DevOps Lesson 14 for CI/CD that deploys, and Team Lead Lesson 3 for review habits. Practise now: take a real branch, make a backup branch, tidy it with git rebase -i, and open a pull request with a description that follows the template from Lesson 3.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series