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.
On this page
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-leaseis 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:
git log --oneline --graph --all # beforegit rebase maingit log --oneline --graph --all # after* d1ad6f4 Add f2* 2a69f59 Add f1| * b11888f Main change 1|/* c824ea5 BaseSuccessfully rebased and updated refs/heads/feature.* c9d1fb7 Add f2* b0d50a6 Add f1* b11888f Main change 1* c824ea5 BaseLook 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:
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 mainNow merging feature into main is a fast-forward, and the history is a straight line:
git switch maingit merge --ff-only featureUpdating b11888f..c9d1fb7Fast-forwardMerge or rebase?
| Merge | Rebase | |
|---|---|---|
| History | Keeps the true shape, with diamonds | Straight line |
| Commits | Unchanged | Copied (new hashes) |
| Conflicts | Resolved once, in the merge commit | May need resolving at each replayed commit |
| Safe on shared branches | Yes | No |
| Good for | Joining shared work, keeping a record | Tidying 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:
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:
<<<<<<< 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:
Successfully rebased and updated refs/heads/topic.* adf862a Topic edit* 948a742 Main edit* d766836 BaseEscape 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:
007b4e0 Add search box0ed76a1 more wip353bd1a fix typoc224eed WIPgit rebase -i mainGit 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:
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:
GIT_SEQUENCE_EDITOR="sed -i -e 2,4s/^pick/squash/" \GIT_EDITOR="sh msg.sh" git rebase -i maingit log --onelinegit log -1 --format=%BSuccessfully 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:
git commit --fixup HEAD~1 # message becomes: fixup! Fix bugGIT_SEQUENCE_EDITOR=true git rebase -i --autosquash mainBefore: 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:
GIT_SEQUENCE_EDITOR="sed -i -e 2s/^pick/reword/" GIT_EDITOR="sh reword.sh" git rebase -i maingit log --oneline3eacead Add y4994df2 Fix crash on empty searchb116992 Add search boxc824ea5 BaseAll 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:
! [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.
git push --force-with-lease + 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:
! [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:
+ 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:
git config pull.rebase true # pull = fetch + rebase, not mergegit config rebase.autoStash true # stash local edits around a rebaseTeam 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-based | Feature branches (GitHub flow) | Gitflow | |
|---|---|---|---|
| Branches | One main; branches live hours | main plus a branch per feature, short or medium | main, develop, feature/*, release/*, hotfix/* |
| Merge to main | Many times a day | After PR review | develop to release to main |
| Incomplete work | Hidden with feature flags | Stays on the branch | Stays on the feature branch |
| Release | Straight from main | From main after merge | From release branches |
| Needs | Good tests, fast CI, flags | PR review and CI | Discipline; slower |
| Best for | Web apps with continuous deployment | Most teams | Versioned 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:
| Setting | What it does |
|---|---|
| Require pull request reviews before merging | Changes reach the branch only through a PR with the set number of approvals |
| Dismiss stale approvals | New commits that change the diff remove earlier approvals |
| Require review from code owners | Owners listed in CODEOWNERS must approve changes to their files |
| Require status checks before merging | Named CI checks must pass; "strict" also requires the branch to be up to date with the base |
| Require conversation resolution | All review comments must be resolved |
| Require signed commits | Only verified signed commits are accepted |
| Require linear history | No merge commits; use squash or rebase merge |
| Restrict who can push | Only chosen people or teams can push |
| Allow force pushes / deletions | Both 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.
# 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-teamRules 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.
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| Line | What it means |
|---|---|
name: CI | The 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: read | Give 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: true | A 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-latest | The kind of machine (a GitHub-hosted Linux runner) |
timeout-minutes: 10 | Stop 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@v5 | Run a ready-made action that copies your repository onto the runner. @v5 pins a version tag |
uses: actions/setup-node@v5 | Install Node.js |
with: node-version: 22 | Inputs for the action: which Node version |
cache: npm | Reuse downloaded npm packages between runs to save time |
run: npm ci | Run a shell command. npm ci installs exactly what the lock file says |
run: npm run lint and npm test | The 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
- In a practice repository make
mainwith one commit, then afeaturebranch with two commits, then add one more commit onmain. Rebasefeatureonmain. Compare the hashes of the two feature commits before and after. - Make four small commits:
WIP,typo,WIP 2,Add login form. Usegit rebase -ito squash them into one commit calledAdd login form. - Make a mistake in a rebase (squash the wrong commits). Undo it with the reflog.
- Create a bare repository as a remote. Push a branch, rebase it, and try to push. What happens? Then use
--force-with-lease. - Write a
CODEOWNERSfile so that/docs/is owned by@acme/writersand everything else by@acme/devs. Which line must come first? - In the workflow above, what would you change so the workflow also runs on pull requests to a
releasebranch? - Name two settings you would turn on first for
mainin a team of five, and say why.
Answers
- The two feature commits have new hashes after the rebase; the messages and content are the same.
git reflogstill lists the old ones. git rebase -i main(orHEAD~4), changepicktosquash(orfixup) on the lines for the three small commits, save, and write the final message in the second editor.git reflogshows entries likerebase (start). Rungit reset --hard HEAD@{N}for the line beforerebase (start), orgit reset --hard ORIG_HEADimmediately.- The push is rejected as non-fast-forward.
git push --force-with-leasesucceeds if nobody else pushed to that branch. * @acme/devsfirst, then/docs/ @acme/writers. The last matching pattern wins, so the specific rule has to come after the general one.- Change
branches: [main]underpull_requesttobranches: [main, release]. - 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 -ilets you pick, reword, squash, fixup or drop commits. Use it to clean your own branch before review.--autosquashfoldsfixup!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
- Git, Team Workflow and Monorepos · Full-Stack Questions
Part of the study guide
Related series