How To

Git Feature Branch Workflow – Develop Without Affecting Main

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.

Original content from computingforgeeks.com - post 71496

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:

  1. To use git switch and git restore, install Git 2.23 or newer.
  2. To commit, set a user name and email in git config.
  3. To push, have a remote repository, or build the practice remote in the next step.
  4. 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.

  1. To create the remote, initialise a bare repository.
  2. To get a working copy, clone it.
  3. To give main something 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:

  1. To force review, require a pull request before merging.
  2. To keep main working, require status checks to pass before merging.
  3. 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.

Git push to protected main rejected by pre-receive hook

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:

PrefixUse it forExample
feature/New functionalityfeature/add-farewell
bugfix/Non-urgent fixesbugfix/null-name
hotfix/Urgent production fixeshotfix/readme-wording
chore/Dependency and tooling updateschore/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:

TaskCurrent commandOlder equivalent
Switch to a branchgit switch maingit checkout main
Create and switchgit switch -c feature/xgit checkout -b feature/x
Discard a file changegit restore app.pygit checkout -- app.py
Unstage a filegit restore --staged app.pygit 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.

Git merge conflict resolved and merge commit 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:

  1. To see what clashed, read git status and the conflict markers.
  2. To resolve it, edit the file to what this commit should contain.
  3. To mark it resolved, run git add on the file.
  4. 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 branchRebase the branch onto main
Conflict stops in this lab12
New commits created1 merge commit3 rewritten commits
Push afterwardsnormal git pushgit push --force-with-lease --force-if-includes
History shapebranches and joinsone straight line
Safe when others share the branchyesonly 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:

  1. To keep a shared branch current, merge main into it, because the merge rewrites nothing your teammates have pulled.
  2. To tidy a branch that only you work on before review, rebase it onto main and push with --force-with-lease --force-if-includes.
  3. 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:

  1. To start the fix, add a worktree on a new branch from origin/main.
  2. To make the fix, edit and commit inside the worktree directory.
  3. To get it reviewed, push the branch and open a merge request.
  4. 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:

ModelLong-lived branchesFeature branch lifetimeUse it when
GitHub Flowmain onlyDaysYou deploy web apps or services continuously
Git Flowmain and developDays to weeksYou ship versioned releases that need stabilisation
Trunk-based developmentmain onlyHours, behind feature flagsYour 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:

  1. To free the branches, switch to main.
  2. To remove local branches, use git branch -d, and -D only for branches you mean to discard.
  3. To remove the copies on the remote, use git push origin --delete.
  4. 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.

Keep reading

Install GitHub CLI (gh) on Linux, macOS, and Windows Programming Install GitHub CLI (gh) on Linux, macOS, and Windows Claude Sonnet 5.5 Released: Benchmarks, Pricing, vs Opus 5.5 AI Claude Sonnet 5.5 Released: Benchmarks, Pricing, vs Opus 5.5 Best Linux Laptops for Programming and Developers (2026) Programming Best Linux Laptops for Programming and Developers (2026) Best Monitor Arms for a Multi-Monitor Developer Desk Programming Best Monitor Arms for a Multi-Monitor Developer Desk Best Mouse and Keyboard for Programming Programming Best Mouse and Keyboard for Programming Claude Haiku 5.5 Released: Pricing, Benchmarks, vs Haiku 4.5 AI Claude Haiku 5.5 Released: Pricing, Benchmarks, vs Haiku 4.5

Leave a Comment

Press ESC to close