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.