Sign up for the KDAB Newsletter
Stay on top of the latest news, publications, events and more.
Go to Sign-up
Find what you need - explore our website and developer resources
2 October 2025
Git is today's most popular distributed version control system (DVCS). Initially built for the Linux kernel's needs and a mailing-list-based review flow, it has proven flexible enough to spread to many other software projects and review flows. In fact, Git's flexibility also means your local workflow doesn't have to match your teams' review flow!
In this blog post, I'm going to present my local workflow when working on multiple changes. It mainly revolves around two relatively new Git features:
--rebase-merges
flag introduced in 2018,--update-refs
flag from 2022.Using them together, I built a workflow where my changes are cleanly split between multiple branches, yet:
switch
branch…using only Git's built-in features.
This blog post is going to be heavily command-line based and you can follow along by copy-pasting. If you prefer a lighter video format, switch to my video on the same subject or watch it at the bottom of this blog.
Let's set up an initial repository in git-remote
with 2 commits and clone it twice into git-local
and git-coworker
.
# To make hashes reproducible, set the same author and date to all commits.
export GIT_AUTHOR_NAME="KDAB's Git rebase-merges demo"
export GIT_AUTHOR_EMAIL="info@kdab.com"
export GIT_AUTHOR_DATE="2025-01-22T15:00+0100"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
# Create a bare repository to be our `origin`
mkdir git-remote
pushd git-remote
git init --bare --initial-branch=main
popd
# Clone and simulate some pre-existing work
git clone git-remote git-local && cd git-local
git commit --allow-empty -m "Root commit"
# [main (root commit) 3657733] Root commit
echo "Feature 0" > feature0
git add feature0
git commit -m "Let's pretend some work happened upstream already"
# [main 4010570] Let's pretend some work happened upstream already
git push
# Also create a second clone so we can simulate coworkers
pushd ..
git clone git-remote git-coworker
popd
# Now your repository should look like this:
git log --all --graph --oneline
# * 4010570 (HEAD -> main, origin/main) Let's pretend some work happened upstream already
# * 3657733 Root commit
git switch -c integration --track origin/main
# branch 'integration' set up to track 'origin/main'.
# Switched to a new branch 'integration'
That's all, we're set up!
Let's not worry about branches for now, and just commit everything linearly to our local integration branch:
echo 'feat 1' > feature1
git add feature1
git commit -m "Feature 1"
# [integration 4f73e16] Feature 1
echo 'bugfix1' >> feature0
git add feature0
git commit -m "Bugfix 1"
# [integration 26aafff] Bugfix 1
echo 'feat 2' > feature2
git add feature2
git commit -m "Feature 2"
# [integration 9fb582c] Feature 2
echo 'feature 1' > feature1
git add feature1
git commit --fixup=4f73e16
# [integration 3ae7dd2] fixup! Feature 1
echo 'feature 2' > feature2
git add feature2
git commit --fixup=9fb582c
# [integration 1b6577f] fixup! Feature 2
echo 'feature 3' > feature3
git add feature3
git commit -m "Feature 3 depends on Feature 2"
# [integration edc0fae] Feature 3 depends on Feature 2
echo 'feature 2 for real' > feature2
git add feature2
git commit --fixup=9fb582c
# [integration 77c318b] fixup! Feature 2
echo 'feature 3 for real' > feature3
git add feature3
git commit --fixup=edc0fae
# [integration 46acd6d] fixup! Feature 3 depends on Feature 2
echo 'feature 1 improved' > feature1
git add feature1
git commit -m "Feature 1 improvement"
# [integration cee864f] Feature 1 improvement
Now we are ready to push our changes for review. But our commits are all mixed up:
git log --graph --oneline
# * cee864f (HEAD -> integration) Feature 1 improvement
# * 46acd6d fixup! Feature 3 depends on Feature 2
# * 77c318b fixup! Feature 2
# * edc0fae Feature 3 depends on Feature 2
# * 1b6577f fixup! Feature 2
# * 3ae7dd2 fixup! Feature 1
# * 9fb582c Feature 2
# * 26aafff Bugfix 1
# * 4f73e16 Feature 1
# * 4010570 (origin/main, main) Let's pretend some work happened upstream already
# * 3657733 Root commit
Let's dispatch them to branches so we can push them for review separately. We will do that with an interactive rebase:
git rebase -i --autosquash --rebase-merges --update-refs
Which initially gives us:
label onto
reset onto
pick 4f73e16 Feature 1
fixup 3ae7dd2 fixup! Feature 1
pick 26aafff Bugfix 1
pick 9fb582c Feature 2
fixup 1b6577f fixup! Feature 2
fixup 77c318b fixup! Feature 2
pick edc0fae Feature 3 depends on Feature 2
fixup 46acd6d fixup! Feature 3 depends on Feature 2
pick cee864f Feature 1 improvement
For each branch we will introduce a block that looks like this:
reset fork-point # where fork-point is a previously-defined label
# picks, rewords, fixups and squashes
# pick just puts the commit in that branch
# reword picks the commit and opens the editor to edit the message
# fixup amends the last commit
# squash amends the last commit, concatenates both messages and opens the editor
update-ref refs/heads/<branch-name> # update (or create!) a branch <branch-name>
label <label-name> # name for use in a later reset or merge in the same rebase
So, here, for instance, to create 3 branches feat/feature1
, feat/feature2
and fix/bugfix1
forking from origin/main
and a branch feat/feature3
forking from feat/feature2
, we would edit that to:
label onto
# Block for the feat/feature1 branch forking from origin/main
reset onto
pick 4f73e16 Feature 1
fixup 3ae7dd2 fixup! Feature 1
pick cee864f Feature 1 improvement
update-ref refs/heads/feat/feature1
label feat1
# Block for the feat/feature2 branch forking from origin/main
reset onto
pick 9fb582c Feature 2
fixup 1b6577f fixup! Feature 2
fixup 77c318b fixup! Feature 2
update-ref refs/heads/feat/feature2
label feat2
# Block for the feat/feature3 branch forking from feat/feature2
reset feat2
pick edc0fae WIP Feature 3 depends on Feature 2
fixup 46acd6d fixup! Feature 3 depends on Feature 2
update-ref refs/heads/feat/feature3
label feat3
# Block for the fix/bugfix1 branch forking from origin/main
reset onto
pick 26aafff WIP Bugfix 1
update-ref refs/heads/fix/bugfix1
label fix1
# Block for the integration branch, merging everything back together
reset onto
merge feat1 feat2 feat3 fix1 # Integration merge commit
# update-ref refs/heads/integration is implicit because it's the current branch
Now our changes are properly dispatched in branches:
git log --graph --oneline
# *---. acd3986 (HEAD -> integration) Integration merge commit
# |\ \ \
# | | | * 0a7b18e (fix/bugfix1) Bugfix 1
# | |_|/
# |/| |
# | | * 4b12f39 (feat/feature3) Feature 3 depends on Feature 2
# | | * fb102ce (feat/feature2) Feature 2
# | |/
# |/|
# | * 901b6a9 (feat/feature1) Feature 1 improvement
# | * efd7697 Feature 1
# |/
# * 4010570 (origin/main, main) Let's pretend some work happened upstream already
# * 3657733 Root commit
Let's just push all those for review:
git push origin feat/feature1 feat/feature2 feat/feature3 fix/bugfix1
# To git-remote
# * [new branch] feat/feature1 -> feat/feature1
# * [new branch] feat/feature2 -> feat/feature2
# * [new branch] feat/feature3 -> feat/feature3
# * [new branch] fix/bugfix1 -> fix/bugfix1
That is if you're using a branch-based review system such as GitHub, GitLab, BitBucket etc.
If you are using Gerrit (the best in my opinion), you would instead git push origin feat/feature1:refs/for/main feat/feature2:refs/for/main feat/feature3:refs/for/main fix/bugfix1:refs/for/main
If you're sending patches to a mailing list (I'm so sorry…), you would use the right git format-patch
and git send-email
calls.
After a few hours or days your teammates will review your code and ask for changes. Sometimes those changes require new commits, but most of the time you should fix up existing commits instead.
Let's pretend that Bugfix 1 was good to go and got merged, and that your coworkers committed some other changes, too:
# Bugfix 1 was merged upstream, some other work happened
pushd ../git-coworker
git pull
echo 'Feature 4' > feature4
git add feature4
git commit -m "Your teammates write and merge code too"
git cherry-pick origin/fix/bugfix1
git push
popd
However, the reviewer noticed that you mishandled some edge case for Feature 1 and asked for a new button to trigger Feature 2. We want to amend the existing Feature 1 commit and add a new commit to the feat/feature2
branch.
Just implement and commit both changes in the integration branch:
sed 's/improved/enhanced/' -i feature1
git add feature1
git commit --fixup=901b6a9
# [integration 507b321] fixup! Feature 1 improvement
echo 'Extra button!' >> feature2
git add feature2
git commit -m "Add button for Feature 2"
# [integration 7cb7e39] Add button for Feature 2
Then update your view of origin/main
by fetching:
git fetch
# From git-remote
# 4010570..0c0b483 main -> origin/main
Then launch an interactive rebase on origin/main
!
git rebase -i --autosquash --rebase-merges --update-refs
# warning: skipped previously applied commit 0a7b18e
label onto
# Branch feat-feature1
reset onto
pick efd7697 Feature 1
pick 901b6a9 Feature 1 improvement
fixup 507b321 fixup! Feature 1 improvement
update-ref refs/heads/feat/feature1
label feat-feature1
# Branch feat-feature3
reset onto
pick fb102ce Feature 2
update-ref refs/heads/feat/feature2
pick 4b12f39 Feature 3 depends on Feature 2
update-ref refs/heads/feat/feature3
label feat-feature3
# Branch Integration-merge-commit
reset onto
label Integration-merge-commit
reset onto
merge -C acd3986 feat-feature1 feat-feature3 Integration-merge-commit # Integration merge commit
pick 7cb7e39 Add button for Feature 2
If your labels are called Integration-merge-commit
Integration-merge-commit-2
and Integration-merge-commit-3
, make sure to update to Git 2.48 or later. I was unhappy with those label names and ended up scratching that itch.
The fixup commit is already in the right place, let's just move the Feature 2 button commit:
label onto
# Branch feat-feature1
reset onto
pick efd7697 Feature 1
pick 901b6a9 Feature 1 improvement
fixup 507b321 fixup! Feature 1 improvement
update-ref refs/heads/feat/feature1
label feat-feature1
# Branch feat-feature3
reset onto
pick fb102ce Feature 2
pick 7cb7e39 Add button for Feature 2
update-ref refs/heads/feat/feature2
pick 4b12f39 Feature 3 depends on Feature 2
update-ref refs/heads/feat/feature3
label feat-feature3
# Branch Integration-merge-commit
reset onto
label Integration-merge-commit
reset onto
merge -C acd3986 feat-feature1 feat-feature3 Integration-merge-commit # Integration merge commit
The Integration-merge-commit block doesn't hurt. In fact it is completely useless and you can also remove it.
Our Git log now looks like this:
git log --graph --oneline
# *-. 63861c2 (HEAD -> integration) Integration merge commit
# |\ \
# | | * ab3ac3e (feat/feature3) Feature 3 depends on Feature 2
# | | * 3acce21 (feat/feature2) Add button for Feature 2
# | | * 23b7df9 Feature 2
# | |/
# |/|
# | * f3046c5 (feat/feature1) Feature 1 improvement
# | * 7e93e38 Feature 1
# |/
# * 0c0b483 (origin/main) Bugfix 1
# * 57c50bf Your teammates write and merge code too
# * 4010570 (main) Let's pretend some work happened upstream already
# * 3657733 Root commit
We can push feature1
, feature2
and feature3
for review again:
git push origin feat/feature1 feat/feature2 feat/feature3 --force-with-lease --force-if-includes
--force-with-lease
and --force-if-includes
ensure that we're not overwriting anything if someone else pushed to those branches.
The workflow I describe here has the advantage of only requiring base Git. You might also like Jujutsu or GitButler, which enable similar workflows.
Jujutsu is a recent Version Control System currently using Git as backend. The main difference with the workflow described here is that jj
automatically rebases commits. When with Git you git commit --fixup=<some_hash> && git rebase -i
, with Jujutsu you only have to jj squash --into <some_hash>
.
See this Jujutsu introduction and, especially, the “Pattern: Working on two things at the same time” section for an example.
GitButler is “a Git client for simultaneous branches on top of your existing workflow“. It provides a nice GUI for this precise use-case. Like Jujutsu it removes the need for git rebase -i
and rebases commits automatically. When with Git you git commit --fixup=<some_hash> && git rebase -i
, with GitButler you drag and drop files to the relevant commit.
See the GitButler docs for more information.
I usually have two kinds of changes I don't want to push upstream: Work-In-Progress commits and local configuration.
I just keep WIP commits at the tip of the integration branch, downstream of the merge commit.
My local configuration lives in a local-do-not-push
branch, merged together with the other feature branches. I just never push it.
This typically looks like this:
git log --graph --oneline
# * 9c28203 (HEAD -> integration) WIP Feature6
# *---. f75840e Integration merge commit
# |\ \ \
# | | | * ab3ac3e (feat/feature3) Feature 3 depends on Feature 2
# | | | * 3acce21 (feat/feature2) Add button for Feature 2
# | | | * 23b7df9 Feature 2
# | |_|/
# |/| |
# | | * f3046c5 (feat/feature1) Feature 1 improvement
# | | * 7e93e38 Feature 1
# | |/
# |/|
# | * 0332875 (local-do-not-push) My local editor config
# |/
# * 0c0b483 (origin/main) Bugfix 1
# * 57c50bf Your teammates write and merge code too
# * 4010570 (main) Let's pretend some work happened upstream already
# * 3657733 Root commit
git log
has a bad default:
By default, the commits are shown in reverse chronological order.
For the example above, I get this nonsense:
git log --oneline
# 9c28203 (HEAD -> integration) WIP Feature6
# f75840e Integration merge commit
# 0c0b483 (origin/main) Bugfix 1
# 0332875 (local-do-not-push) My local editor config
# f3046c5 (feat/feature1) Feature 1 improvement
# ab3ac3e (feat/feature3) Feature 3 depends on Feature 2
# 57c50bf Your teammates write and merge code too
# 7e93e38 Feature 1
# 3acce21 (feat/feature2) Add button for Feature 2
# 4010570 (main) Let's pretend some work happened upstream already
# 23b7df9 Feature 2
# 3657733 Root commit
Therefore, I recommend always using either --graph
as above or at least --topo-order
:
git log --topo-order --oneline
# 9c28203 (HEAD -> integration) WIP Feature6
# f75840e Integration merge commit
# ab3ac3e (feat/feature3) Feature 3 depends on Feature 2
# 3acce21 (feat/feature2) Add button for Feature 2
# 23b7df9 Feature 2
# f3046c5 (feat/feature1) Feature 1 improvement
# 7e93e38 Feature 1
# 0332875 (local-do-not-push) My local editor config
# 0c0b483 (origin/main) Bugfix 1
# 57c50bf Your teammates write and merge code too
# 4010570 (main) Let's pretend some work happened upstream already
# 3657733 Root commit
Unfortunately, this doesn't have a git config
option… yet!
Typing git rebase -i --rebase-merges --update-refs --autosquash
every time is quite heavy. Your shell history should make this easier, but still… Luckily, those can be set in the git config:
git config --global rebase.rebasemerges true
git config --global rebase.updaterefs true
git config --global rebase.autoSquash true
Editing the rebase todo-list takes some time getting used to. While Git generates all the boilerplate for existing branches, creating a new branch takes at least:
t onto
u refs/heads/<branch_name>
l <label>
m <label>
Fortunately, most editors have support for macros!
For Kate, I have a macro that writes the boilerplate above and sets the cursor at the end of the update-refs
, label
and merge
lines. After writing the branch/label name I just have to move lines around.
My colleague Magnus Groß wrote a Vim plugin to efficiently create new branches, move commits around and even push directly from the git rebase todo-list, you can find it here.
Stay tuned for more tooling improvements around this workflow!
About KDAB
The KDAB Group is a globally recognized provider for software consulting, development and training, specializing in embedded devices and complex cross-platform desktop applications. In addition to being leading experts in Qt, C++ and 3D technologies for over two decades, KDAB provides deep expertise across the stack, including Linux, Rust and modern UI frameworks. With 100+ employees from 20 countries and offices in Sweden, Germany, USA, France and UK, we serve clients around the world.
Stay on top of the latest news, publications, events and more.
Go to Sign-up
Need help with performance issues?
Let the KDAB experts solve concrete performance problems, improve your software architecture or teach your team how to apply debugging and profiling tools to your developement process in the best way.
Get in touch