You can build a feature in Git without touching main by doing all of the work on a short-lived branch. You keep the branch current with a merge or a rebase, and main changes only through a reviewed merge. What goes wrong is conflicts, rewritten history and a main anyone can push to.
In this guide, you run the Git feature branch workflow against a real remote: protect main, branch with git switch, resolve a real conflict, compare merge with rebase, and fix a bug from a git worktree. A local bare repository stands in for GitHub or GitLab, so every error below is reproducible without an account.
Last run in October 2026 on Ubuntu 26.04 with Git 2.53.0 and a local bare repository as the remote.
Before you begin
You need a terminal and Git. Any Git release from the last few years works, because git switch and git restore arrived in Git 2.23 and everything else here is older. Check what you have before you start:
- To use
git switchandgit restore, install Git 2.23 or newer. - To commit, set a user name and email in
git config. - To push, have a remote repository, or build the practice remote in the next step.
- To finish the conflict exercises, allow about 15 minutes.
Install Git from your distribution’s packages, because every current release ships a version new enough for this guide. On RHEL-family systems, our guide to installing Git on Rocky Linux and AlmaLinux covers the package and a newer build. On Ubuntu and Debian, sudo apt install git is enough. Confirm the version:
git --version
The test machine reported the release below. Anything from 2.23 onward behaves the same for these steps, although newer releases print slightly different hint lines during conflicts:
git version 2.53.0
If you have never used Git on this machine, set your identity. Without it your first commit either fails or goes in under an address Git guessed from your login and hostname, which nobody can map back to you:
git config --global user.name "Maureen Barasa"
git config --global user.email "[email protected]"
Replace the name and email with your own. Follow the rest of the guide on the practice remote from the next section, not on a team repository, because several steps deliberately push to main or rewrite a pushed branch, which you never want to do to shared work.
Set the default branch name as well, even if Git is already configured on your machine. A clone of an empty repository takes its first branch name from this setting, and without it the branch is master, so the git push -u origin main in the next section fails with error: src refspec main does not match any (we hit exactly that on a fresh account):
git config --global init.defaultBranch main
The setting only affects new repositories, so your existing projects keep their branch names.
Set up a practice remote
A bare repository is a Git repository with no working files, which is exactly what GitHub, GitLab and Gitea store on their servers. Pushes and fetches behave the same way, because the platforms speak the same Git protocol underneath their web interface. Using one on your own machine gives you a real origin to push to, with no account and no risk to a shared project. If you later want the same thing with a web interface and pull requests, a self-hosted Gitea server is the natural next step.
- To create the remote, initialise a bare repository.
- To get a working copy, clone it.
- To give
mainsomething to protect, add a first commit and push it.
Create the bare repository first:
mkdir -p ~/git-server
git init --bare ~/git-server/webapp.git
Git confirms the empty repository:
Initialized empty Git repository in /home/maureen/git-server/webapp.git/
Clone it to get your working copy, and change into it, since every later command runs inside the clone. Git warns that the repository is empty. That is expected at this point:
git clone ~/git-server/webapp.git ~/webapp
cd ~/webapp
The clone succeeds with that warning.
Cloning into '/home/maureen/webapp'...
warning: You appear to have cloned an empty repository.
done.
Add a small Python file and a README, commit them and push main. The app only needs two functions, because the point is to give two branches something to disagree about later. The files are written with printf so you can paste the whole block, but you can create them in any editor as long as the content matches, since the conflict later depends on the exact greet() line:
printf '%s\n' 'def greet(name):' ' return "Hello, " + name' > app.py
printf '%s\n' '# webapp' 'A small demo app.' > README.md
git add app.py README.md
git commit -m "Initial commit: greet function and README"
git push -u origin main
The push creates main on the remote and sets it as the upstream for your local branch, so later pushes and pulls on main need no arguments:
[main (root-commit) 0895400] Initial commit: greet function and README
2 files changed, 4 insertions(+)
create mode 100644 README.md
create mode 100644 app.py
To /home/maureen/git-server/webapp.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
Your commit hashes will differ from the ones in this guide, because a hash covers the author, the time and the content. Everything else should match.
Add branch protection to main
The feature branch workflow only keeps main clean when nobody can push to main directly. On GitHub, you enforce that with a branch protection rule or a ruleset that requires a pull request; on GitLab, with a protected branch that allows no one to push. Set this up before the team starts, because a single direct push skips review and CI entirely. With classic branch protection on GitHub, repository admins can still bypass the rules unless you also enable the option that applies them to administrators; rulesets instead let through only the roles and apps on their bypass list. On a hosted platform, turn on at least these settings for main:
- To force review, require a pull request before merging.
- To keep
mainworking, require status checks to pass before merging. - To protect history, leave force pushes and branch deletion blocked.
GitHub documents every option in About protected branches, and GitLab calls the same feature protected branches.
A bare repository has no settings page, so this lab uses a server-side pre-receive hook as a stand-in. GitHub.com and GitLab.com do not run custom hooks like this (only GitHub Enterprise Server and GitLab self-managed do), so on a hosted service the protection settings above are the real control. The hook reads every ref a push wants to update and rejects the push when one of them is main. The GL_USER check lets one identity through to play the platform’s merge button. It is not authentication: it works only because a push over the local file transport passes your environment to the hook, and the variable name is borrowed from gitolite. Write the hook with printf and make it executable:
printf '%s\n' '#!/bin/sh' 'while read old new ref; do' ' if [ "$ref" = "refs/heads/main" ] && [ "$GL_USER" != "release-bot" ]; then' ' echo "main is protected: open a merge request instead of pushing to it"' ' exit 1' ' fi' 'done' > ~/git-server/webapp.git/hooks/pre-receive
chmod +x ~/git-server/webapp.git/hooks/pre-receive
Print the file to confirm the hook text before you rely on it, because a quoting mistake in the printf line produces a hook that silently allows everything.
cat ~/git-server/webapp.git/hooks/pre-receive
The hook is short enough to read in full.
#!/bin/sh
while read old new ref; do
if [ "$ref" = "refs/heads/main" ] && [ "$GL_USER" != "release-bot" ]; then
echo "main is protected: open a merge request instead of pushing to it"
exit 1
fi
done
Test the protection the way an accident happens, on the practice remote only: commit on main and push. The remote should refuse the push and leave main unchanged, so that nothing reaches the shared branch without review. If the push succeeds, the hook is not executable or has the wrong name:
printf '%s\n' '# a direct edit' >> README.md
git commit -am "Direct edit on main"
git push
The push is rejected, and the hook’s message reaches you through the remote: line:
[main bb25fb7] Direct edit on main
1 file changed, 1 insertion(+)
remote: main is protected: open a merge request instead of pushing to it
To /home/maureen/git-server/webapp.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/home/maureen/git-server/webapp.git'
The same rejection on the lab VM, followed by the reset in the next step, looks like this.

The commit still exists in your local main, so throw it away to put your copy back in step with the remote. git reset --hard discards the commit and any uncommitted changes, so use it only when you are sure nothing in the working tree matters:
git reset --hard origin/main
git status -sb
Your main matches origin/main again:
HEAD is now at 0895400 Initial commit: greet function and README
## main...origin/main
Leave the protection in place for the rest of the guide, because the merge step depends on it.
Create a feature branch
Start every feature from an up-to-date main, because a branch created from a stale main begins life already behind and conflicts sooner. The longer a branch lives, the more main moves under it, so aim for branches that merge within a few days rather than weeks. git switch changes branches and git switch -c creates one. Both arrived in Git 2.23 to split the overloaded git checkout into two commands that each do one job:
git switch main
git pull --ff-only
git switch -c feature/add-farewell
--ff-only refuses to create a merge commit on main if your local copy has somehow diverged, which is the safe default for a branch you never commit to directly. If it fails, someone committed to your local main by mistake, so reset it to origin/main instead of merging. The output confirms you are current and on the new branch:
Already on 'main'
Your branch is up to date with 'origin/main'.
Already up to date.
Switched to a new branch 'feature/add-farewell'
git branch lists local branches and marks the current one with an asterisk. Use it whenever you are unsure where your next commit will land.
git branch
You are on the feature branch.
* feature/add-farewell
main
Agree on branch name prefixes with your team, because CI rules, protection rules and cleanup scripts often match on them:
| Prefix | Use it for | Example |
|---|---|---|
feature/ | New functionality | feature/add-farewell |
bugfix/ | Non-urgent fixes | bugfix/null-name |
hotfix/ | Urgent production fixes | hotfix/readme-wording |
chore/ | Dependency and tooling updates | chore/bump-ci-image |
Push your commits to the branch
Make small commits that each do one thing, because a reviewer can read them in order and you can revert one without losing the others. A commit message should say what changed and why, in the imperative, the way Git’s own messages read (“Add farewell function”, not “added stuff”). These two commits add a function and document it:
printf '%s\n' '' 'def farewell(name):' ' return "Goodbye, " + name' >> app.py
git add app.py
git commit -m "Add farewell function"
printf '%s\n' '' 'Functions: greet, farewell.' >> README.md
git commit -am "Document farewell in README"
To see only the commits that your branch adds on top of main, use the two-dot range main..HEAD. This is the list a reviewer sees in the pull request, so check it before you push rather than after a reviewer finds a stray commit:
git log --oneline main..HEAD
Both commits are on the branch and not on main:
63b2369 Document farewell in README
0e08886 Add farewell function
Push the branch to the remote as soon as it has a commit, so that your work is backed up and visible to the team even before it is ready for review. The -u flag records origin/feature/add-farewell as the upstream, so later you can run a plain git push or git pull without naming the remote and branch:
git push -u origin feature/add-farewell
The remote accepts the new branch, because the hook only guards main:
To /home/maureen/git-server/webapp.git
* [new branch] feature/add-farewell -> feature/add-farewell
branch 'feature/add-farewell' set up to track 'origin/feature/add-farewell'.
git branch -vv shows each local branch with its upstream and last commit. Run it to confirm the tracking is in place.
git branch -vv
Each branch points at its counterpart on origin.
* feature/add-farewell 63b2369 [origin/feature/add-farewell] Document farewell in README
main 0895400 [origin/main] Initial commit: greet function and README
If your team runs CI on every branch, this push is what triggers the first pipeline. A multibranch pipeline in Jenkins or GitLab CI discovers the new branch on its own, so push early rather than waiting until the feature is finished. Failures then show up while the change is still small.
Use git restore to undo changes
git restore is the second half of the checkout split: it discards changes to files, while git switch handles branches. Use it when an edit goes wrong before you commit. To see it work, damage app.py and then restore it from the last commit:
echo "garbage" >> app.py
git status --short
git restore app.py
git status --short
tail -3 app.py
git status --short shows M app.py before the restore and nothing after it. The file ends with the committed farewell function again, because git restore copies the version in the index (the staging area) over your edit, and with nothing staged that matches the last commit. The edit is gone for good, since Git never stored it:
M app.py
def farewell(name):
return "Goodbye, " + name
git restore --staged removes a file from the staging area but keeps your edit in the working tree, which is how you undo an accidental git add. Run both forms on the README to see the difference.
echo "draft" >> README.md
git add README.md
git restore --staged README.md
git status --short
git restore README.md
git status --short
After --staged, the change is unstaged but still present ( M README.md). After the plain restore, the working tree is clean and the second status prints nothing, so the only output is the first status line.
M README.md
The older git checkout forms still work, so you will see them in scripts and older documentation:
| Task | Current command | Older equivalent |
|---|---|---|
| Switch to a branch | git switch main | git checkout main |
| Create and switch | git switch -c feature/x | git checkout -b feature/x |
| Discard a file change | git restore app.py | git checkout -- app.py |
| Unstage a file | git restore --staged app.py | git reset HEAD app.py |
Open a pull or merge request
A pull request on GitHub and a merge request on GitLab are the same thing: a proposal to merge your branch into main, with review comments and CI results attached. Open one as soon as the branch is pushed, even as a draft, so reviewers can follow the work. The two platforms’ command-line tools create them in one command:
gh pr create --base main --head feature/add-farewell --title "Add farewell function"
glab mr create --source-branch feature/add-farewell --target-branch main --title "Add farewell function"
These two commands need a GitHub or GitLab project, so they were not part of the bare-repository run. Our GitHub CLI cheat sheet covers gh pr in detail, and the PR vs MR comparison explains how the terms differ between platforms. In the pull request description, state the problem the branch solves, how you tested it and where you want the reviewer to look first.
Update the branch from main with a merge
While you work, other people merge into main. To simulate that, a teammate clone changes the same greet() line you are about to change, and the release-bot identity pushes it to main the way a platform merge would. Run this only against the practice remote. Both edits are reasonable on their own, which is what makes real conflicts hard to avoid:
git clone -q ~/git-server/webapp.git ~/teammate
cd ~/teammate
sed -i 's/return "Hello, " + name/return f"Hello, {name}!"/' app.py
git commit -qam "Use f-string in greet"
GL_USER=release-bot git push origin main
cd ~/webapp
Back on your branch, change the same line in a different way and commit it.
sed -i 's/return "Hello, " + name/return "Hello, " + name.title()/' app.py
git commit -am "Title-case names in greet"
Fetch the remote and look at the history. git fetch only downloads, so it never changes your branch or your files. The graph shows that main has moved on and your branch has three commits that main has never seen:
git fetch origin
git log --oneline --graph --all
The two lines of work diverge from the initial commit.
From /home/maureen/git-server/webapp
0895400..3eba969 main -> origin/main
* 644a04b Title-case names in greet
* 63b2369 Document farewell in README
* 0e08886 Add farewell function
| * 3eba969 Use f-string in greet
|/
* 0895400 Initial commit: greet function and README
Merging origin/main into your branch brings in the teammate’s change and gives you the chance to fix any conflict on your branch, before the reviewer sees it. Doing it here rather than at merge time also means CI tests the combined code on your branch, so a broken combination never reaches main. Run the merge:
git merge origin/main
Both commits changed the same line, so Git stops and asks you to decide:
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
Automatic merge failed; fix conflicts and then commit the result.
Check the state of the merge before you edit anything.
git status
git status lists the conflicted file under “Unmerged paths” and tells you how to finish or abort:
On branch feature/add-farewell
Your branch is ahead of 'origin/feature/add-farewell' by 1 commit.
(use "git push" to publish your local commits)
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: app.py
no changes added to commit (use "git add" and/or "git commit -a")
Print app.py (or open it in your editor) and read the conflict markers.
cat app.py
Everything between <<<<<<< HEAD and ======= is your branch’s version, and everything between ======= and >>>>>>> origin/main is the incoming version from main:
def greet(name):
<<<<<<< HEAD
return "Hello, " + name.title()
def farewell(name):
return "Goodbye, " + name
=======
return f"Hello, {name}!"
>>>>>>> origin/main
Git does not stop you from committing a file that still contains these markers: git add and git commit accept it. git diff --check is the guard, because it reports every leftover marker:
git diff --check
It lists each marker line and exits with status 2, so you can also use it as a check in your scripts or a pre-commit hook:
app.py:2: leftover conflict marker
app.py:7: leftover conflict marker
app.py:9: leftover conflict marker
Notice that the farewell function sits inside the conflict, although nobody else touched it. Git groups nearby changed lines into one hunk, so always read the whole conflict block before you pick a side. The right resolution keeps both intentions: the f-string from main, the title-casing from your branch and the new function. Write the resolved file, run git diff --check again (it prints nothing once the markers are gone), stage the file to mark the conflict as fixed, and commit:
printf '%s\n' 'def greet(name):' ' return f"Hello, {name.title()}!"' '' 'def farewell(name):' ' return "Goodbye, " + name' > app.py
git diff --check
git add app.py
git commit --no-edit
git log --oneline --graph -6
The merge commit joins both lines of history.
[feature/add-farewell 06bcdac] Merge remote-tracking branch 'origin/main' into feature/add-farewell
* 06bcdac Merge remote-tracking branch 'origin/main' into feature/add-farewell
|\
| * 3eba969 Use f-string in greet
* | 644a04b Title-case names in greet
* | 63b2369 Document farewell in README
* | 0e08886 Add farewell function
|/
* 0895400 Initial commit: greet function and README
The screenshot shows the same merge on the lab VM, from the conflict to the final graph.

A resolved conflict is only correct if the code still works, so run it before you push. Git does not check that the result makes sense, and a resolution that keeps both sides of a function definition is valid Git but broken code. This check calls both functions:
python3 -B -c 'import app; print(app.greet("ada lovelace")); print(app.farewell("ada"))'
Both changes survived the merge:
Hello, Ada Lovelace!
Goodbye, ada
Push the merged branch. Because the merge only added commits on top of what the remote already had, a normal push is enough:
git push
The remote branch moves forward:
To /home/maureen/git-server/webapp.git
63b2369..06bcdac feature/add-farewell -> feature/add-farewell
The feature branch now contains the teammate’s change and yours, and it is ready for review.
Use a rebase instead of a merge
A rebase replays your commits one by one on top of the new main, so the branch ends up with a straight history and no merge commit. The price is that every replayed commit gets a new hash, which matters as soon as anyone else has the old commits. To compare the two approaches on the same work, create a second branch from your commit before the merge (HEAD^1) and push it, as a real branch would already be on the remote:
git switch -c feature/add-farewell-rebase HEAD^1
git push -u origin feature/add-farewell-rebase
Then rebase it onto origin/main:
git rebase origin/main
The first replayed commit already conflicts. In a terminal, Git redraws the Rebasing (n/m) counter in place; the captured output below shows each update on its own line. This is the “Add farewell function” commit, because it touched the lines next to greet():
Rebasing (1/3)
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
error: could not apply 0e08886... Add farewell function
hint: Resolve all conflicts manually, mark them as resolved with
hint: "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".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 0e08886... # Add farewell function
During a rebase you resolve each commit as it was at that point in history, not the final result. Each stop follows the same loop:
- To see what clashed, read
git statusand the conflict markers. - To resolve it, edit the file to what this commit should contain.
- To mark it resolved, run
git addon the file. - To move to the next commit, run
git rebase --continue.
At this commit, the title-casing does not exist yet, so the correct resolution is main‘s f-string plus the new function. GIT_EDITOR=true accepts the existing commit message without opening an editor:
printf '%s\n' 'def greet(name):' ' return f"Hello, {name}!"' '' 'def farewell(name):' ' return "Goodbye, " + name' > app.py
git add app.py
GIT_EDITOR=true git rebase --continue
The second commit applies cleanly, and the third, “Title-case names in greet”, stops on its own conflict. That is the cost of a rebase: you may resolve the same area of code once per commit, rather than once for the whole branch:
[detached HEAD 5d41036] Add farewell function
1 file changed, 3 insertions(+)
Rebasing (2/3)
Rebasing (3/3)
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
error: could not apply 644a04b... Title-case names in greet
hint: Resolve all conflicts manually, mark them as resolved with
hint: "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".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 644a04b... # Title-case names in greet
git diff during a conflict shows both sides of the hunk. Here the conflict is now a single line, which makes the decision easy:
git diff
The current base has the f-string and the replayed commit has the title-casing.
diff --cc app.py
index 0880bc4,42de176..0000000
--- a/app.py
+++ b/app.py
@@@ -1,5 -1,5 +1,9 @@@
def greet(name):
++<<<<<<< HEAD
+ return f"Hello, {name}!"
++=======
+ return "Hello, " + name.title()
++>>>>>>> 644a04b (Title-case names in greet)
def farewell(name):
return "Goodbye, " + name
Resolve it to the combined version and continue.
printf '%s\n' 'def greet(name):' ' return f"Hello, {name.title()}!"' '' 'def farewell(name):' ' return "Goodbye, " + name' > app.py
git add app.py
GIT_EDITOR=true git rebase --continue
git log --oneline --graph -5
The rebase finishes and the history is a straight line with your three commits on top of the teammate’s, while the commit hashes are all new.
[detached HEAD cc2f161] Title-case names in greet
1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/feature/add-farewell-rebase.
* cc2f161 Title-case names in greet
* 4792989 Document farewell in README
* 5d41036 Add farewell function
* 3eba969 Use f-string in greet
* 0895400 Initial commit: greet function and README
Rebasing gave every commit a new hash, so your local branch and the copy on the remote have diverged. Check how Git sees the branch now.
git status -sb
Git reports the branch as both ahead of and behind its remote copy:
## feature/add-farewell-rebase...origin/feature/add-farewell-rebase [ahead 4, behind 3]
A normal push fails in that state.
git push
Git refuses a push that would discard the remote’s three commits, since it cannot tell a rebase from a mistake.
To /home/maureen/git-server/webapp.git
! [rejected] feature/add-farewell-rebase -> feature/add-farewell-rebase (non-fast-forward)
error: failed to push some refs to '/home/maureen/git-server/webapp.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
Do not follow the hint and run git pull here, because that merges the old commits back in and doubles your history. Use --force-with-lease instead of --force: it overwrites the remote branch only if it still points where your last fetch saw it. That protection is weaker than it sounds, because a background fetch (an IDE, a cron job, gh) updates your remote-tracking ref and the lease then passes even though you never looked at the new commit. Add --force-if-includes (Git 2.30 and newer), which also checks that the remote tip is part of your local history, so a teammate’s commit you have not seen blocks the push. If the push is refused, fetch, inspect their commit and rebase again before you retry:
git push --force-with-lease --force-if-includes
Git reports a forced update:
To /home/maureen/git-server/webapp.git
+ 644a04b...cc2f161 feature/add-farewell-rebase -> feature/add-farewell-rebase (forced update)
Anyone who had pulled the old version of this branch now has to reset their copy, which is the main reason to rebase only branches that you own.
Choose merge, rebase or squash
Both runs above started from the same three commits and the same change on main, so you can compare them directly:
Merge main into the branch | Rebase the branch onto main | |
|---|---|---|
| Conflict stops in this lab | 1 | 2 |
| New commits created | 1 merge commit | 3 rewritten commits |
| Push afterwards | normal git push | git push --force-with-lease --force-if-includes |
| History shape | branches and joins | one straight line |
| Safe when others share the branch | yes | only after you coordinate |
The merge stopped once because Git compared the two end states. The rebase stopped twice because it replayed each commit separately, and two of the three touched the conflicting lines. On a long branch with many small commits, that difference grows, so a branch with twenty commits that keep editing the same function can mean twenty conflict stops on rebase and one on merge. Rewriting also matters more than the stop count when others have pulled your branch, so use these rules:
- To keep a shared branch current, merge
maininto it, because the merge rewrites nothing your teammates have pulled. - To tidy a branch that only you work on before review, rebase it onto
mainand push with--force-with-lease --force-if-includes. - To land a feature as one commit on
main, squash it at merge time.
A squash merge collapses the whole branch into one set of staged changes that you commit yourself. To try it without touching main, create a throwaway branch from origin/main and squash the rebased branch into it:
git switch -c squash-demo origin/main
git merge --squash feature/add-farewell-rebase
git status --short
git commit -qm "Add farewell and title-case greet (squashed)"
git log --oneline -3
Git stages both files without committing, and your commit becomes the single new entry in the log.
Switched to a new branch 'squash-demo'
branch 'squash-demo' set up to track 'origin/main'.
Updating 3eba969..cc2f161
Fast-forward
Squash commit -- not updating HEAD
README.md | 2 ++
app.py | 5 ++++-
2 files changed, 6 insertions(+), 1 deletion(-)
M README.md
M app.py
4e442b5 Add farewell and title-case greet (squashed)
3eba969 Use f-string in greet
0895400 Initial commit: greet function and README
The trade-off with squashing is that the individual commits disappear from main, so git blame points at one large commit and you cannot revert just one step of the feature. Git also no longer sees the feature branch as merged into main, because none of its commits are there, so tools that list merged branches will not offer it for cleanup. If your team relies on small commits for review and reverts, prefer a merge commit.
Push the merge to a protected main
On GitHub or GitLab, the merge happens when someone with the right permission clicks the merge button after review and CI pass. Locally, you can prepare the same merge and see the protection hold, which is a useful test after you change protection rules on a real platform. Switch to main, update it and merge the feature branch with --no-ff, which always creates a merge commit, so the feature stays visible as one unit in the history and can be reverted with a single git revert -m 1:
git switch main
git pull --ff-only
git merge --no-ff feature/add-farewell -m "Merge branch 'feature/add-farewell'"
git push
The merge succeeds locally and the push is rejected, which is exactly what the protection is for.
Switched to branch 'main'
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
(use "git pull" to update your local branch)
Updating 0895400..3eba969
Fast-forward
app.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
Merge made by the 'ort' strategy.
README.md | 2 ++
app.py | 5 ++++-
2 files changed, 6 insertions(+), 1 deletion(-)
remote: main is protected: open a merge request instead of pushing to it
To /home/maureen/git-server/webapp.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/home/maureen/git-server/webapp.git'
In this lab, the release-bot identity plays the platform’s merge button, and this push lands on the practice remote’s main. Push as that identity to complete the merge, so that main changes only through the one path you allowed:
GL_USER=release-bot git push
main on the remote now includes the feature.
To /home/maureen/git-server/webapp.git
3eba969..39c15bb main -> main
After the merge, the feature branch has done its job, and the clean-up section removes it.
Use a worktree for an urgent fix
git worktree checks out a second branch into a separate directory that shares the same repository. Use it when you need to fix something on main while your feature branch has uncommitted work, because you do not have to stash, commit half-done work or clone again. The flow has four steps:
- To start the fix, add a worktree on a new branch from
origin/main. - To make the fix, edit and commit inside the worktree directory.
- To get it reviewed, push the branch and open a merge request.
- To tidy up, remove the worktree once the branch is pushed.
Start from the feature branch with an uncommitted edit, so you can see that it survives:
git switch feature/add-farewell-rebase
echo "wip" >> README.md
git status --short
The README has an uncommitted change.
Switched to branch 'feature/add-farewell-rebase'
Your branch is up to date with 'origin/feature/add-farewell-rebase'.
M README.md
Create a worktree for the hotfix branch, based on the current origin/main rather than your feature branch, because the fix must not carry any unreviewed feature code:
git worktree add -b hotfix/readme-wording ../webapp-hotfix origin/main
Git creates the directory and the branch in one step.
Preparing worktree (new branch 'hotfix/readme-wording')
branch 'hotfix/readme-wording' set up to track 'origin/main'.
HEAD is now at 39c15bb Merge branch 'feature/add-farewell'
Make the fix in the new directory and commit it there. Your feature checkout is not touched, because each worktree has its own files and its own index:
cd ../webapp-hotfix
sed -i 's/A small demo app./A small demo application./' README.md
git commit -qam "Fix README wording"
git log --oneline -2
git push -u origin hotfix/readme-wording
cd ../webapp
git worktree list
The hotfix commit sits on top of main. Push it with an explicit -u origin hotfix/readme-wording, because the worktree branch was created tracking origin/main, and a plain git push would refuse to push to a branch with a different name under the default push.default=simple. git worktree list then shows both checkouts.
e5a98c8 Fix README wording
39c15bb Merge branch 'feature/add-farewell'
To /home/maureen/git-server/webapp.git
* [new branch] hotfix/readme-wording -> hotfix/readme-wording
branch 'hotfix/readme-wording' set up to track 'origin/hotfix/readme-wording'.
/home/maureen/webapp cc2f161 [feature/add-farewell-rebase]
/home/maureen/webapp-hotfix e5a98c8 [hotfix/readme-wording]
Back in the original checkout, check the uncommitted edit:
git status --short
The work in progress survived:
M README.md
A branch can only be checked out in one worktree at a time, which protects you from editing the same branch in two places and losing track of which copy holds the latest change. Try to switch the main checkout to the hotfix branch:
git switch hotfix/readme-wording
Git refuses and tells you where your branch is checked out.
fatal: 'hotfix/readme-wording' is already used by worktree at '/home/maureen/webapp-hotfix'
In a real project, you would now open a merge request for the pushed hotfix branch. When you are done with the directory, remove the worktree; the branch and its commit stay in the repository:
git worktree remove ../webapp-hotfix
git worktree list
Only the main checkout remains:
/home/maureen/webapp cc2f161 [feature/add-farewell-rebase]
The practice edit in your feature checkout is still there. Discard it now, so it does not follow you into the next section:
git restore README.md
The README in your feature checkout is back to its committed state.
Pick a branching model
The commands above work the same in every branching model. What changes is which branch you start from, how long a branch lives and how many branches stay around permanently. Pick the model before the first feature branch, because changing it later means retraining everyone and rewriting the protection rules:
| Model | Long-lived branches | Feature branch lifetime | Use it when |
|---|---|---|---|
| GitHub Flow | main only | Days | You deploy web apps or services continuously |
| Git Flow | main and develop | Days to weeks | You ship versioned releases that need stabilisation |
| Trunk-based development | main only | Hours, behind feature flags | Your CI is fast and reliable enough for every small merge |
If you are choosing for a new team, start with GitHub Flow and the protection rule from this guide. It needs the fewest rules, and you can move to trunk-based development once your tests are fast enough. For deeper study of branching, rebasing and history rewriting, our pick of books to master Git lists the titles worth a team’s shelf space, and a terminal UI such as lazygit makes staging hunks during a conflict much faster.
Clean up
Delete branches once they are merged, so your branch list only shows the work you still have in progress. Work through the cleanup in this order:
- To free the branches, switch to
main. - To remove local branches, use
git branch -d, and-Donly for branches you mean to discard. - To remove the copies on the remote, use
git push origin --delete. - To drop stale references in other clones, run
git fetch --prune.
git branch -d deletes a branch only when its commits are safe. Switch to main and delete the merged feature branch:
git switch main
git branch -d feature/add-farewell
The branch is merged into main, so Git deletes it without complaint.
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
Deleted branch feature/add-farewell (was 06bcdac).
The rebased branch was never merged into main, yet -d still deleted it, with a warning. Git also accepts a branch as safe when it is fully pushed to its upstream:
git branch -d feature/add-farewell-rebase
The warning names the upstream that holds the commits.
warning: deleting branch 'feature/add-farewell-rebase' that has been merged to
'refs/remotes/origin/feature/add-farewell-rebase', but not yet merged to HEAD
Deleted branch feature/add-farewell-rebase (was cc2f161).
A branch with commits that exist nowhere else is refused. To see the refusal, create a quick experiment branch with one commit and try to delete it from main:
git switch -c experiment/cache
echo "# cache idea" >> app.py
git commit -qam "Try a cache"
git switch main
git branch -d experiment/cache
Git protects the unmerged commit and tells you how to override it.
Switched to a new branch 'experiment/cache'
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
error: the branch 'experiment/cache' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D experiment/cache'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
Delete the experiment and the other practice branches with -D once you are sure you do not need them.
git branch -D experiment/cache squash-demo hotfix/readme-wording
Each one is removed, and Git prints the hash so you can recover it with git branch <name> <hash> if you change your mind:
Deleted branch experiment/cache (was df5bc07).
Deleted branch squash-demo (was 4e442b5).
Deleted branch hotfix/readme-wording (was e5a98c8).
Remote branches need their own delete, because git branch -d only touches your clone. On GitHub and GitLab, you can turn on automatic deletion of the head branch after merge, which saves this step. Delete the practice branches from the remote:
git push origin --delete feature/add-farewell feature/add-farewell-rebase hotfix/readme-wording
The remote confirms each deletion.
To /home/maureen/git-server/webapp.git
- [deleted] feature/add-farewell
- [deleted] feature/add-farewell-rebase
- [deleted] hotfix/readme-wording
Other clones still have remote-tracking references to the deleted branches until they prune, so git branch -a keeps listing branches that no longer exist. Run git fetch --prune in each clone, here the teammate’s. Setting git config --global fetch.prune true makes every fetch do this:
cd ~/teammate
git fetch --prune
git branch -a
The stale reference is removed, and the clone picks up the merged main.
From /home/maureen/git-server/webapp
- [deleted] (none) -> origin/feature/add-farewell
3eba969..39c15bb main -> origin/main
* main
remotes/origin/HEAD -> origin/main
remotes/origin/main
To remove the practice environment completely, delete the three directories. This removes the practice remote and both clones, so run it only in the lab:
rm -rf ~/webapp ~/teammate ~/git-server
Your real repositories are not affected, because everything in this guide lived under those three paths.