Last updated on
Distributed version control
Below is a transcript demonstrating Git commands related to branching, merging, and rebasing.
I start by cloning the webapp-lib
repository, to have some example code to work with:
~/git/cs214 $ git clone https://gitlab.epfl.ch/cs214/ul2024/webapp-lib.git Cloning into 'webapp-lib'... remote: Enumerating objects: 462, done. remote: Counting objects: 100% (383/383), done. remote: Compressing objects: 100% (230/230), done. remote: Total 462 (delta 98), reused 0 (delta 0), pack-reused 79 (from 1) Receiving objects: 100% (462/462), 97.25 KiB | 1.13 MiB/s, done. Resolving deltas: 100% (104/104), done. ~/git/cs214 $ cd webapp-lib/ ~/git/cs214/webapp-lib (main) $ git status On branch main Your branch is up to date with 'origin/main'. nothing to commit, working tree clean
To get a reliable starting point, I create a new branch starting from one of the tagged releases. I use git tag
to get the list of tags, and git branch
+ git switch
to create a branch called demo
.
~/git/cs214/webapp-lib (main) $ git tag … v0.7.0 v0.8.0 ~/git/cs214/webapp-lib (main) $ git branch demo v0.8.0 ~/git/cs214/webapp-lib (main) $ git switch demo Switched to branch 'demo' ~/git/cs214/webapp-lib (demo) $
Branching
I now create three branches starting from demo
. This time I use git switch -c
for succinctness.
disallow-unregistered-users
~/git/cs214/webapp-lib (demo) $ git switch -b disallow-unregistered-users Switched to a new branch 'disallow-unregistered-users'
On this first branch, I change webapp-lib to allow any user to connect to any application. This is convenient for apps that don’t have a predefined list of users:
~/git/cs214/webapp-lib (disallow-unregistered-users) $ code .
(Notice how the prompt changed after I ran git switch
.)
Once my changes are ready, I stage and commit them:
$ git status On branch disallow-unregistered-users Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala no changes added to commit (use "git add" and/or "git commit -a") ~/git/cs214/webapp-lib (disallow-unregistered-users) $ git diff diff --git a/jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala b/jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala index c874a46..abbbebe 100644 --- a/jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala +++ b/jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala @@ -57,6 +57,8 @@ private[web] final class WebSocketsCollection(val port: Int): case Array(appId, userId) => if !sessions.contains(appId) then throw IllegalArgumentException("Error: Invalid app ID") + if !WebServer.apps(appId).instance.registeredUsers.contains(userId) then + throw IllegalArgumentException("Error: Invalid user ID") k(appId, userId) case _ => throw Exception("Error: Invalid path") catch ~/git/cs214/webapp-lib (disallow-unregistered-users) $ git commit -m "Disallow connections by unregistered users" -m "This way, state machine implementations do not have to handle this case themselves: unregistered users will never reach the 'transition' function." [disallow-unregistered-users 8c95b32] Disallow connections by unregistered users 1 file changed, 2 insertions(+)
error-missing-ui
On the second branch, I change Pages.scala
to give a better error message when no app UI can be found:
~/git/cs214/webapp-lib (disallow-unregistered-users) $ git switch -c error-missing-ui demo Switched to a new branch 'error-missing-ui' ~/git/cs214/webapp-lib (error-missing-ui) $ code .
~/git/cs214/webapp-lib (error-missing-ui) $ git diff diff --git a/js/src/main/scala/cs214/webapp/client/Pages.scala b/js/src/main/scala/cs214/webapp/client/Pages.scala index bb916f0..5b3cc31 100644 --- a/js/src/main/scala/cs214/webapp/client/Pages.scala +++ b/js/src/main/scala/cs214/webapp/client/Pages.scala @@ -158,7 +158,10 @@ case class UIPage(appId: AppId, instanceId: InstanceId) extends Page: WebClient.navigateTo(JoinPageLoader(appId, ui.uiId, instanceId)) def renderInto(target: Element) = - if appUIs.size <= 1 then + if appUIs.size == 0 then + replaceChildren(target): + frag(pageHeader(f"No registered UI for app {appId}")) + else if appUIs.size == 1 then WebClient.navigateTo(JoinPageLoader(appId, appUIs(0).uiId, instanceId)) else replaceChildren(target): dom.window.addEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e))
~/git/cs214/webapp-lib (error-missing-ui) $ git commit -m "Show a clear error message for missing UIs" -m "Otherwise students get an exception when they try to launch an app that doesn't have a UI." [error-missing-ui fba1529] Show a clear error message for missing UIs 1 file changed, 4 insertions(+), 1 deletion(-)
better-doc
On the last branch I document a function of the Page
trait:
~/git/cs214/webapp-lib (error-missing-ui) $ git switch -c better-page-doc demo Switched to a new branch 'better-page-doc' ~/git/cs214/webapp-lib (better-page-doc) $ code . $ git diff diff --git a/js/src/main/scala/cs214/webapp/client/Pages.scala b/js/src/main/scala/cs214/webapp/client/Pages.scala index bb916f0..4e96eab 100644 --- a/js/src/main/scala/cs214/webapp/client/Pages.scala +++ b/js/src/main/scala/cs214/webapp/client/Pages.scala @@ -11,6 +11,8 @@ import scalatags.JsDom.all.* /** Parent class to web pages */ abstract class Page: val classList: String + + /** Render the current page, replacing the children of `target`. **/ def renderInto(target: dom.Element): Unit def pageHeader(subtitle: String) = ~/git/cs214/webapp-lib (better-page-doc) $ git add .; git commit -m 'Document `Page.renderInto`' [better-page-doc 4bc8a75] Document `Page.renderInto` 1 file changed, 2 insertions(+)
Merging
One by one
Back to demo
, I now want to incorporate these changes onto my demo
branch. I use merge
for this:
$ git switch demo Switched to branch 'demo' ~/git/cs214/webapp-lib (demo) $ git merge better-page-doc --no-edit Updating 6c5ce95..4bc8a75 Fast-forward js/src/main/scala/cs214/webapp/client/Pages.scala | 2 ++ 1 file changed, 2 insertions(+) ~/git/cs214/webapp-lib (demo) $ git merge disallow-unregistered-users --no-edit Merge made by the 'ort' strategy. jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala | 2 ++ 1 file changed, 2 insertions(+) ~/git/cs214/webapp-lib (demo) $ git merge error-missing-ui --no-edit Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala Merge made by the 'ort' strategy. js/src/main/scala/cs214/webapp/client/Pages.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-)
Notice that the first merge is a “fast-forward”: since there were no changes on demo
, there’s no need to create a merge commit: demo
can just be moved forward by one step. In contrast, the third merge requires a bit more care by Git, since both branches edited the same file (that’s what the “Auto-merging” note above refers to).
What happens if I try to merge a branch a second time? Nothing bad! If the branch hasn’t advanced since the last merge then nothing happens at all; otherwise an additional merge commit is created:
~/git/cs214/webapp-lib (demo) $ git merge error-missing-ui --no-edit Already up to date.
To inspect the structure of my commit graph, I can use git log
:
~/git/cs214/webapp-lib (demo) $ git log --oneline --graph * 992998a (HEAD -> demo) Merge branch 'error-missing-ui' into demo |\ | * fba1529 (error-missing-ui) Show a clear error message for missing UIs * | e59ce02 Merge branch 'disallow-unregistered-users' into demo |\ \ | * | 8c95b32 (disallow-unregistered-users) Disallow connections by unregistered users | |/ * / 4bc8a75 (better-page-doc) Document `Page.renderInto` |/ * 6c5ce95 (tag: v0.8.0) Attribute EFF wordlist in LICENSE
All at once
Alternatively, I can merge all branches at once. I start by resetting demo
to v0.8.0
:
~/git/cs214/webapp-lib (demo) $ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE
I can then call merge
with all branch names:
~/git/cs214/webapp-lib (demo) $ git merge disallow-unregistered-users error-missing-ui better-page-doc Fast-forwarding to: disallow-unregistered-users Trying simple merge with error-missing-ui Trying simple merge with better-page-doc Simple merge did not work, trying automatic merge. Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala Merge made by the 'octopus' strategy. js/src/main/scala/cs214/webapp/client/Pages.scala | 7 ++++++- jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-)
This is called an “octopus” merge, because the resulting commit has 4 parents (the tips of my three feature branches, plus demo
).
Handling merge conflicts
We were lucky enough that Git handled our three changesets automatically. What happens if two branches touch the same code? Let’s start by creating a second fix for the missing-UI error message:
$ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE ~/git/cs214/webapp-lib (demo) $ git switch -c throw-missing-ui demo Switched to a new branch 'throw-missing-ui' ~/git/cs214/webapp-lib (throw-missing-ui) $ code . ~/git/cs214/webapp-lib (throw-missing-ui) $ git diff diff --git a/js/src/main/scala/cs214/webapp/client/Pages.scala b/js/src/main/scala/cs214/webapp/client/Pages.scala index bb916f0..d8b697c 100644 --- a/js/src/main/scala/cs214/webapp/client/Pages.scala +++ b/js/src/main/scala/cs214/webapp/client/Pages.scala @@ -158,6 +158,8 @@ case class UIPage(appId: AppId, instanceId: InstanceId) extends Page: WebClient.navigateTo(JoinPageLoader(appId, ui.uiId, instanceId)) def renderInto(target: Element) = + if appUIs.isEmpty then + throw IllegalAccessException("No UIs registered.") if appUIs.size <= 1 then WebClient.navigateTo(JoinPageLoader(appId, appUIs(0).uiId, instanceId)) else replaceChildren(target): ~/git/cs214/webapp-lib (throw-missing-ui) $ git add .; git commit -m "Throw if app has no registered UI" [throw-missing-ui 934ab3d] Throw if app has no registered UI 1 file changed, 2 insertions(+)
Back to demo
, we merge the first branch without problem, but the second one causes trouble:
$ git switch demo Switched to branch 'demo' ~/git/cs214/webapp-lib (demo) $ git merge error-missing-ui Updating 6c5ce95..fba1529 Fast-forward js/src/main/scala/cs214/webapp/client/Pages.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) ~/git/cs214/webapp-lib (demo) $ git merge throw-missing-ui Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala CONFLICT (content): Merge conflict in js/src/main/scala/cs214/webapp/client/Pages.scala Automatic merge failed; fix conflicts and then commit the result. $ git diff --diff-filter=U diff --cc js/src/main/scala/cs214/webapp/client/Pages.scala index 5b3cc31,d8b697c..0000000 --- a/js/src/main/scala/cs214/webapp/client/Pages.scala +++ b/js/src/main/scala/cs214/webapp/client/Pages.scala @@@ -158,10 -158,9 +158,18 @@@ case class UIPage(appId: AppId, instanc WebClient.navigateTo(JoinPageLoader(appId, ui.uiId, instanceId)) def renderInto(target: Element) = ++<<<<<<< HEAD + if appUIs.size == 0 then + replaceChildren(target): + frag(pageHeader(f"No registered UI for app {appId}")) + else if appUIs.size == 1 then ++||||||| 6c5ce95 ++ if appUIs.size <= 1 then ++======= + if appUIs.isEmpty then + throw IllegalAccessException("No UIs registered.") + if appUIs.size <= 1 then ++>>>>>>> throw-missing-ui WebClient.navigateTo(JoinPageLoader(appId, appUIs(0).uiId, instanceId)) else replaceChildren(target): dom.window.addEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e))
The contents of Pages.scala
indicate the issue:
def renderInto(target: Element) =
<<<<<<< HEAD
if appUIs.size == 0 then
replaceChildren(target):
frag(pageHeader(f"No registered UI for app {appId}"))
else if appUIs.size == 1 then
||||||| 6c5ce95
if appUIs.size <= 1 then
=======
if appUIs.isEmpty then
throw IllegalAccessException("No UIs registered.")
if appUIs.size <= 1 then
>>>>>>> throw-missing-ui
WebClient.navigateTo(JoinPageLoader(appId, appUIs(0).uiId, instanceId))
The top part is from demo
, and results from the merge of error-missing-ui
. The bottom part is from throw-missing-ui
. The middle part is their common ancestor, v0.8.0
.
This “three-way” diff style is used only if you tell git to do so, with
$ git config --global merge.conflictstyle diff3
To fix the issue, I edit the file by hand, keeping a bit of each branch:
if appUIs.isEmpty then
replaceChildren(target):
frag(pageHeader(f"No registered UI for app {appId}"))
else if appUIs.size == 1 then
… and I commit the result:
~/git/cs214/webapp-lib (demo) $ git add js/src/main/scala/cs214/webapp/client/Pages.scala ~/git/cs214/webapp-lib (demo) $ git merge --continue [demo 08c9012] Merge branch 'throw-missing-ui' into demo
Does a successful merge guarantee the absence of bugs in the merged code? Are all automatic merges safe?
Solution
No. For example, if two separate commits add the same function in two different places, the result of the merge will have two copies, and the resulting code may not even compile.
Rebasing
Merging branches creates a new kind of commit: a merge commit, with two or more parents in the commit graph. Sometimes this is desirable (we want to record explicitly the fact that two development paths converged), but not always. As an alternative, we can rebase our work by replaying individual commits:
Let’s return to our original demo branch:
~/git/cs214/webapp-lib (demo) $ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE
With cherry-picks
The simplest approach is to transfer the commits one by one. This is called cherry-picking:
~/git/cs214/webapp-lib (demo) $ git cherry-pick disallow-unregistered-users error-missing-ui better-page-doc [demo 82fa262] Disallow connections by unregistered users Date: Wed Nov 27 10:44:44 2024 +0100 1 file changed, 2 insertions(+) [demo 5c346b8] Show a clear error message for missing UIs Date: Wed Nov 27 11:02:08 2024 +0100 1 file changed, 4 insertions(+), 1 deletion(-) Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala [demo a7aefda] Document `Page.renderInto` Date: Wed Nov 27 11:06:21 2024 +0100 1 file changed, 2 insertions(+)
This works because I have just one commit per branch, so mentioning just the branch name is enough. Alternatively, I could write explicitly that I want the whole range of commits:
~/git/cs214/webapp-lib (demo) $ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE ~/git/cs214/webapp-lib (demo) $ git cherry-pick demo...disallow-unregistered-users demo...error-missing-ui demo...better-page-doc [demo 37288e9] Document `Page.renderInto` Date: Wed Nov 27 11:06:21 2024 +0100 1 file changed, 2 insertions(+) Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala [demo 2f3dd95] Show a clear error message for missing UIs Date: Wed Nov 27 11:02:08 2024 +0100 1 file changed, 4 insertions(+), 1 deletion(-) [demo 6f80a59] Disallow connections by unregistered users Date: Wed Nov 27 10:44:44 2024 +0100 1 file changed, 2 insertions(+)
Cherry-picking is essentially a thin veneer on top of format-patch
and am
.
With git rebase
This pattern is so common that Git has a command for it: git rebase
. Let’s reset demo
:
~/git/cs214/webapp-lib (demo) $ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE
Then rebase the branches one by one:
~/git/cs214/webapp-lib (demo) $ git switch error-missing-ui Switched to branch 'error-missing-ui' 3:45:53 ~/git/cs214/webapp-lib (error-missing-ui) $ git rebase --onto disallow-unregistered-users demo Successfully rebased and updated refs/heads/error-missing-ui.
~/git/cs214/webapp-lib (error-missing-ui) $ git switch better-page-doc Switched to branch 'better-page-doc' ~/git/cs214/webapp-lib (better-page-doc) $ git rebase --onto error-missing-ui demo Successfully rebased and updated refs/heads/better-page-doc.
We now have one linear history:
~/git/cs214/webapp-lib (better-page-doc) $ git log --oneline b47fa68 (HEAD -> better-page-doc) Document `Page.renderInto` 49e4066 (error-missing-ui) Show a clear error message for missing UIs 8c95b32 (disallow-unregistered-users) Disallow connections by unregistered users 6c5ce95 (tag: v0.8.0, demo) Attribute EFF wordlist in LICENSE …
… which we can fast-foward demo
to:
~/git/cs214/webapp-lib (better-page-doc) $ git switch demo Switched to branch 'demo' ~/git/cs214/webapp-lib (demo) $ git merge --ff-only better-page-doc Updating 6c5ce95..b47fa68 Fast-forward js/src/main/scala/cs214/webapp/client/Pages.scala | 7 ++++++- jvm/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-)
With a custom plan
The last option is to rebase interactively: this lets you tell git exactly how to reconstruct history by picking commits one by one, and possibly reordering and rephrasing them. This option is left as an exercise!
Handling rebase conflicts
Just like merging, rebasing can lead to conflicts. To demonstrate this, we first restore our branches to their non-rebased states:
~/git/cs214/webapp-lib (better-page-doc) $ git switch demo Switched to branch 'demo' ~/git/cs214/webapp-lib (demo) $ git reset --hard v0.8.0 HEAD is now at 6c5ce95 Attribute EFF wordlist in LICENSE ~/git/cs214/webapp-lib (demo) $ git switch error-missing-ui Switched to branch 'error-missing-ui' ~/git/cs214/webapp-lib (error-missing-ui) $ git rebase --onto demo HEAD~ Successfully rebased and updated refs/heads/error-missing-ui. $ git switch better-page-doc Switched to branch 'better-page-doc' ~/git/cs214/webapp-lib (better-page-doc) $ git rebase --onto demo HEAD~ Successfully rebased and updated refs/heads/better-page-doc.
Then we can attempt a rebase that creates conflicts:
~/git/cs214/webapp-lib (demo) $ git switch throw-missing-ui Switched to branch 'throw-missing-ui' ~/git/cs214/webapp-lib (throw-missing-ui) $ git rebase error-missing-ui Auto-merging js/src/main/scala/cs214/webapp/client/Pages.scala CONFLICT (content): Merge conflict in js/src/main/scala/cs214/webapp/client/Pages.scala error: could not apply f0ec848... Throw if app has no registered UI 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". Could not apply f0ec848... Throw if app has no registered UI
git status
tells us precisely what the issue is, and git diff
shows us the same diff as for the previous merge conflict:
~/git/cs214/webapp-lib () $ git status interactive rebase in progress; onto d0bd0e1 Last command done (1 command done): pick f0ec848 Throw if app has no registered UI No commands remaining. You are currently rebasing branch 'throw-missing-ui' on 'd0bd0e1'. (fix conflicts and then run "git rebase --continue") (use "git rebase --skip" to skip this patch) (use "git rebase --abort" to check out the original branch) Unmerged paths: (use "git restore --staged <file>..." to unstage) (use "git add <file>..." to mark resolution) both modified: js/src/main/scala/cs214/webapp/client/Pages.scala no changes added to commit (use "git add" and/or "git commit -a")
~/git/cs214/webapp-lib () $ git diff diff --cc js/src/main/scala/cs214/webapp/client/Pages.scala index 1482ac2,d8b697c..0000000 --- a/js/src/main/scala/cs214/webapp/client/Pages.scala +++ b/js/src/main/scala/cs214/webapp/client/Pages.scala @@@ -158,10 -158,9 +158,18 @@@ case class UIPage(appId: AppId, instanc WebClient.navigateTo(JoinPageLoader(appId, ui.uiId, instanceId)) def renderInto(target: Element) = ++<<<<<<< HEAD + if appUIs.size == 1 then + replaceChildren(target): + frag(pageHeader(f"No registered UI for app {appId}")) + else if appUIs.size == 1 then ++||||||| parent of f0ec848 (Throw if app has no registered UI) ++ if appUIs.size <= 1 then ++======= + if appUIs.isEmpty then + throw IllegalAccessException("No UIs registered.") + if appUIs.size <= 1 then ++>>>>>>> f0ec848 (Throw if app has no registered UI) WebClient.navigateTo(JoinPageLoader(appId, appUIs(0).uiId, instanceId)) else replaceChildren(target): dom.window.addEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e))
The resolution process is similar to git merge
: we fix the conflict by hand, and apply it with git add
and git rebase --continue
:
~/git/cs214/webapp-lib () $ git add js/src/main/scala/cs214/webapp/client/Pages.scala ~/git/cs214/webapp-lib () $ git rebase --continue [detached HEAD 80950f2] Throw if app has no registered UI 1 file changed, 1 insertion(+), 1 deletion(-) Successfully rebased and updated refs/heads/throw-missing-ui.
Can a rebase lead to conflict if the corresponding merge has none? Which approach will produce the fewest conflicts?
Solution
Rebasing will often lead to more conflicts. Consider two branches:
- On branch A, you implemented algorithm 1, then switched to algorithm 2.
- On branch B, you implemented algorithm 2 directly.
In the end, both branches have the same code, so the merge will go smoothly.
In contrast, if you attempt to rebase branch A on branch B, both commits will cause conflicts.