Last updated on

Week 9: Webapps and more Version Control

Welcome to week 9 of CS-214 — Software Construction!

As usual, ⭐️ indicates the most important exercises and questions and 🔥 indicates the most challenging ones. Exercises or questions marked 🧪 are intended to build up to concepts used in this week’s lab.

You do not need to complete all exercises to succeed in this class, and you do not need to do all exercises in the order they are written.

Webapps

This exercise set is intended to reinforce some concepts used in the webapp lab: serialization, and state machines.

We strongly encourage you to solve the exercises on paper first, in groups. After completing a first draft on paper, you may want to check your solutions on your computer. To do so, you can pull from the course exercises repository.

Serialization / deserialization 🧪

The traditional way to handle exceptional results or errors in functional programming is the Option type, which we studied in the calculator lab. At times, this style can be a bit heavy. We’ll see why it may still be worth using in the monads week, but for this week’s exercises (and lab!) let’s use exceptions instead.

We’ll illustrate the issue with a serialization problem.

Serialization is the process of transforming data within a program into a form that can be transmitted over a network, stored in files, etc. Deserialization is the reverse operation. Once your program has state, it’s pretty natural to want to save it, and for that you need serialization.

A common serialization format is JSON. In Scala, a JSON value can be represented like this:

package ujson

enum Value:
  case Null
  case True
  case False
  case Str(s: String)
  case Num(d: Double)
  case Arr(items: ArrayBuffer[Value])
  case Obj(items: LinkedHashMap[String, Value])

A serialization function for type T is a function of type T => ujson.Value. A deserialization function is a function of type ujson.Value => Try[T]. The type Try[T] models either a Success(t: T) or a Failure, and comes with a very convenient constructor Try(…) which runs the computation , and returns a Failure if it raises an exception, and a Success otherwise. A wire is a pair of a serialization and a deserialization function; it can be represented thus:

trait Wire[T]:
  def serialize(t: T): ujson.Value
  def deserialize(js: ujson.Value): Try[T]

webapps/src/main/scala/serialization/Wire.scala

As an example, here are a serializer and a deserializer for a pair of an integer and a string:

object IntStringWire extends Wire[(Int, String)]:
  def serialize(t: (Int, String)): ujson.Value =
    ujson.Arr(
      ujson.Num(t._1),
      ujson.Str(t._2)
    )

  def deserialize(js: ujson.Value): Try[(Int, String)] =
    Try {
      val arr = js.arr // .arr throws an exception if the input isn't an array
      val (fst, snd) = (arr(0).num, arr(1).str)
      if !fst.isValidInt then
        throw IllegalArgumentException(f"Not an int: $fst")
      (fst.toInt, snd)
    }

webapps/src/main/scala/serialization/Wire.scala

And if we are given a serializer and deserializer for some type T, we can use it to create a serializer and deserializer for Option[T]:

case class OptionWire[T](w: Wire[T]) extends Wire[Option[T]]:
  def serialize(t: Option[T]): ujson.Value =
    t match
      case None =>
        ujson.Arr(ujson.Str("none"))
      case Some(t) =>
        ujson.Arr(ujson.Str("some"), w.serialize(t))

  def deserialize(js: ujson.Value): Try[Option[T]] =
    Try {
      val arr = js.arr
      val tag = arr(0).str
      if arr.size == 1 && arr(0).str == "none" then
        None
      else if arr.size == 2 && arr(0).str == "some" then
        Some(w.deserialize(arr(1)).get)
      else
        throw IllegalArgumentException(f"Unexpected: ${arr.toList}")
    }

webapps/src/main/scala/serialization/Wire.scala

  1. Read the documentation of the Try type.

  2. Skim through the examples provided in the documentation of the ujson library (you can skip the last section on Converting To/From Scala Data Types).

  3. Consider the following type:

    enum Formula:
      case Lit(b: Boolean)
      case Var(name: String)
      case FnCall(fn: String, args: List[Formula])
    

    webapps/src/main/scala/serialization/Wire.scala

    How would you serialize a value of this type? Think about representation choices on paper first. Try a few different examples by hand. Then, write the serializer and deserializer.

    object FormulaWire extends Wire[Formula]:
      import Formula.*
    
      def serialize(e: Formula): ujson.Value =
        ???
    
      def deserialize(js: ujson.Value): Try[Formula] =
        ???
    

    webapps/src/main/scala/serialization/Wire.scala

  4. All encoder/decoder pairs should verify deserialize(serialize(v)) === Success(v) for all v. Use this equation to test your serializer and deserializer together.

    (This is necessary but not sufficient for correctness; do you see why?)

  5. A previous version of this exercise had the following assertion in a test, to check for “correct handling of invalid inputs”, but students complained:

    assert(FormulaWire.deserialize("notaformula").isFailure)
    

    Can you see what was wrong with this test?

  1. Here is one way to write the serializer and deserializer:

    object FormulaWire extends Wire[Formula]:
      import Formula.*
    
      extension (self: Formula)
        def tag =
          self match
            case Lit(d)           => "Lit"
            case Var(name)        => "Var"
            case FnCall(fn, args) => "FnCall"
    
      def serialize(e: Formula): ujson.Value =
        ujson.Obj(
          "tag" -> ujson.Str(e.tag),
          "value" -> {
            e match
              case Lit(d)    => ujson.Bool(d)
              case Var(name) => ujson.Str(name)
              case FnCall(fn, args) => ujson.Obj(
                  "fn" -> ujson.Str(fn),
                  "args" -> ujson.Arr(args.map(serialize)*)
                )
          }
        )
    
      def deserialize(js: ujson.Value): Try[Formula] =
        def loop(js: ujson.Value): Formula =
          val obj = js.obj
          val value = obj("value")
          obj("tag").str match
            case "Lit" => Lit(value.bool)
            case "Var" => Var(value.str)
            case "FnCall" =>
              val fnCallObj = value.obj
              FnCall(fnCallObj("fn").str, fnCallObj("args").arr.toList.map(loop))
        Try(loop(js))
    

    webapps/src/main/scala/serialization/Wire.scala

    Note that ujson has some support for implicit conversions, so it’s possible to use a "fn" in place of ujson.Str("fn"), for example.

  2. The equation doesn’t check that deserialize behaves correctly on invalid inputs. If we give a nonsensical JSON object to deserialize, we want it to throw a meaningful error, not randomly crash, delete files, execute arbitrary code, or whatever else. In other words, we want a separate equation like “for all JSON values js, if js is not the serialization of a valid object, then deserialize should reject it”.

    As a bonus question: could we test this using deserialize(js).map(serialize).foreach(js2 => assertEq(js, js2))?

    Spoiler

    No, because even with one fixed encoding scheme there may be multiple valid encodings for the same object (we could have optional compression, or have arrays whose elements are listed in arbitrary order, etc.). Hence, deserializing then re-serializing may not produce the original JSON value, even for a correct wire.

    If you’re curious about these issues with roundtrip semantics, have a look at this paper, or wait for next year’s computer security class!

  3. There are many possible encoding schemes for the Formula type. Representing a Var as a plain (untagged) JSON string is reasonable, yet it would fail this test: deserialize("notaformula") would produce Success(Var("notaformula")). In this case, the test was bad, not the code!

Isolating state: state machines 🧪

Most stateful applications are thoroughly stateful: there’s state everywhere, and anything can change under your feet at any time. A better model, when you must have state, is to contain it in one place. This is the state machine model, where you have a single piece of state, and a function to update it.

You have already seen many examples of this pattern, without knowing it:

Another way to put this is that we have a state and instructions that are interpreted to update the state. In that sense, foldLeft and foldRight are interpreters for a language whose instructions are the elements of the list being processed.

This week’s lab will demonstrate how powerful this model is for building reliable single- and multi-user applications for desktop, mobile, and web environments. For now, let’s get used to thinking in terms of state machines. We’ll use the following model:

trait StateMachine[State, Event, Result]:
  def init: State
  def update(s: State, e: Event): State // Sometimes called *transition*
  def finish(s: State): Result

webapps/src/main/scala/rec/StateMachines.scala

def processEvents[S, E, R](sm: StateMachine[S, E, R])(events: List[E]): R =
  sm.finish(events.foldLeft(sm.init)(sm.update))

webapps/src/main/scala/rec/StateMachines.scala

Why do we bother with all this if it all boils down to a foldLeft? Because in general we are interested in modeling streaming processes, where events are user inputs, messages on a network, interrupts, etc. In that case, instead of a foldLeft, we would store the state in a var and update it every time we receive an event from the user, the network, a sensor, etc. (and there would be no finish stage)

Here is a simple example: counting how many times the word "CS" is followed by "214" in a list of words. The state has two parts:

case class State(lastWord: Option[String], occurencesCount: Int)

webapps/src/main/scala/rec/StateMachines.scala

lastWord remembers which word we saw last, and occurences stores the ongoing count.

The events and results are simpler: events are simply words, and the final result is a number:

type Event = String

webapps/src/main/scala/rec/StateMachines.scala

type Result = Int

webapps/src/main/scala/rec/StateMachines.scala

We can now define the state machine itself:

object PatternMatchingSM extends StateMachine[State, Event, Result]:

webapps/src/main/scala/rec/StateMachines.scala

… it starts with no previous word and a count of 0:

def init = State(None, 0)

webapps/src/main/scala/rec/StateMachines.scala

… every time we receive a new word, it updates its state to increment the count if needed, and replaces the last word:

def update(s: State, e: Event) =
  State(
    Some(e),
    s.occurencesCount
      + (if s.lastWord == Some("CS") && e == "214" then 1 else 0)
  )

webapps/src/main/scala/rec/StateMachines.scala

… and finally, when the stream is exhausted, it just returns the final count:

def finish(s: State) = s.occurencesCount

webapps/src/main/scala/rec/StateMachines.scala

Now, how do we use this state machine? With processEvents!

val countMatchesSM: List[String] => Int =
  processEvents(PatternMatching.PatternMatchingSM)

webapps/src/main/scala/rec/StateMachines.scala

Notice the currying: we could have also written things this way:

def countMatchesSM2(l: List[String]): Int =
  processEvents(PatternMatching.PatternMatchingSM)(l)

webapps/src/main/scala/rec/StateMachines.scala

… and then called the resulting function as usual:

scala> countMatchesSM(List("A", "special", "CS", "214", "exercise"))
1

If you find yourself unsure of how this function really works, try tracing processEvents — it helps a lot to see exactly which states are being traversed.

Counting words ⭐️

This isn’t in fact the first time that we see state machines — we’ve seen them once before, when we wrote wordCount using foldLeft:

case class WordCountState(count: Int, lastWasWS: Boolean)

def wordCount(l: List[Char]): Int =
  l.foldLeft[WordCountState](WordCountState(0, true))((state: WordCountState, c: Char) =>
    val cIsWS = c.isWhitespace
    val count = state.count + (if state.lastWasWS && !cIsWS then 1 else 0)
    WordCountState(count, cIsWS)
  ).count

webapps/src/main/scala/rec/StateMachines.scala

Rewrite this function as a state machine.

object WordCount:
  case class State(count: Int, lastWasWS: Boolean)
  type Event = Char
  type Result = Int

  object WordCountSM extends StateMachine[State, Event, Result]:
    def init = State(0, true)

    def update(s: State, e: Event) =
      val cIsWS = e.isWhitespace
      val count = s.count + (if s.lastWasWS && !cIsWS then 1 else 0)
      State(count, cIsWS)

    def finish(s: State) =
      s.count

  val wordCountSM: List[Char] => Int =
    processEvents(WordCount.WordCountSM)

webapps/src/main/scala/rec/StateMachines.scala

Counting parentheses

When studying parallelism, we solved the parentheses-balancing problem in the following way:

def isBalanced(str: List[Char]): Boolean =
  def loop(str: List[Char], numOpen: Int): Int =
    if numOpen < 0 then numOpen
    else
      str match
        case Nil           => numOpen
        case '(' :: next   => loop(next, numOpen + 1)
        case ')' :: next   => loop(next, numOpen - 1)
        case other :: next => loop(next, numOpen)
  loop(str, 0) == 0

webapps/src/main/scala/rec/StateMachines.scala

Rewrite this function as a state machine.

object IsBalanced:
  enum State:
    case Invalid
    case Valid(numOpen: Int)
  type Event = Char
  type Result = Boolean

  object IsBalancedSM extends StateMachine[State, Event, Result]:
    import State.*

    def init = Valid(0)

    def update(s: State, e: Event) =
      (s, e) match
        case (Valid(0), ')') => Invalid
        case (Valid(n), '(') => Valid(n + 1)
        case (Valid(n), ')') => Valid(n - 1)
        case _               => s

    def finish(s: State) =
      s match
        case Invalid  => false
        case Valid(n) => n == 0

  val isBalancedSM: List[Char] => Boolean =
    processEvents(IsBalancedSM)

webapps/src/main/scala/rec/StateMachines.scala

Git II: Distributed version control

Here are a few questions, exercises, and resources to guide your exploration of version control systems (part 2). Please do ask on Ed or in exercise sessions if anything is unclear!

If you ask a complex Git question on Ed, it will often be much easier for us to help you debug if you include a copy of your repo. For this, post your repo as a git bundle in a private Ed post (look up the documentation of git bundle to know what it does).

Make sure you have good backups before experimenting with your computer on the command line!

These exercises are written with just enough information to specify what to do, but they don’t specify how to do it. We expect you to look things up in the manual pages, online, or to discuss it among yourselves. But whichever source you use, make sure that you understand what you are doing! Don’t just blindly copy-paste commands.

Using a Git UI

Some advanced Git users prefer the command line, but most have a favorite UI. Decide whether you prefer to work on the command line or graphically. If you decide to go the graphical route, make sure that UI supports the following features:

The UI that I demonstrated in class was Magit, a package for Emacs. Staff members use a mix of Emacs, Neovim, VS Code, plain command line, and a few other editors.

Configure Git text editor

If you plan to use the command line, configure which text editor Git will use for editing commit messages and other interactions. See Git I for tips on how to do this.

Creating branches ⭐️

  1. Download the scaffold for this week’s FP exercises. Unzip it, and initialize a local Git repository in the root directory of the exercises.

  2. Commit the scaffold on the main branch.

  3. Create a branch for each exercise. Commit your solutions regularly as you make progress on each exercise. Make sure that you know how to:

    1. Create a branch
    2. Switch between branches
    3. Compare two branches with git diff
  1. Once you have downloaded and unzipped this week’s FP exercises scaffold,

    • Command line: Run git init -b main in the top-level directory (the one containing the .gitignore file, the src subdirectory, etc.). -b main ensures the default branch is called main, which is the name the rest of the solutions will refer to.

    • VS Code:

      1. File > Open Folder > Select the top-level directory for the repository.
      2. Once the directory is opened, View > Open View > Select Source Control.
      3. Click the button Initialize Repository.
      4. Your repository should now be created. The rest of the solutions assume your default branch is called main. You can ensure this by going to View > Command Palette, typing Rename Branch and selecting the corresponding command. Then enter main.
  2. Commit the template for this week’s FP exercises on the main branch.

    • Command line:

      git add .
      git commit -m "Add FP exercises"
      

      (By command-line convention, the path . refers to the current directory. git add . means add (stage) all files in the current directory. To add only some files or directories, you can replace . with the corresponding path(s).)

    • VS Code: To do the whole commit process in VS Code (not only writing the commit message), open the Source Control view, write a commit message, and click Commit. Since you did not stage your changes, VS Code will ask you to confirm that you want to commit all the changed files. If you want to commit only some files, click on the + icon for files you want to commit in the Source Control screen.

  3. Do the exercises and regularly commit your progress, on a different branch for each exercise.

    1. To create a branch:

      • Command line: Use git branch name to create a new branch. Note that Git will not automatically switch to the branch you just created. See the next step for how to switch branches.

      • VS Code: Select View > Command Palette, select the base branch (here, main), and provide a name for the new branch. VS Code will automatically switch to the created branch.

      (For your own curiosity (optional), you can read about how branches should be named. You might not care much when working on a personal project, but in a team, naming conventions matter.)

    2. To switch between branches:

      First, note that while you can technically switch between branches with changes still not committed, it is in general easier and safer to have a clean working directory (unstaged, tracked files) and index (staged files) before switching, unless you know what you’re doing.

      • Command line: Use git switch branch-name to switch to the branch named branch-name. You might even use git switch -c branch-name to both create the branch and switch to it.

        Old tutorials may still mention a different command for changing branches: git checkout. This complex command has been replaced by two simpler ones (git switch and git restore), and there is little point in using git checkout these days.

      • VS Code: In the Source Control view, click on the name of the current branch. A dialog will let you select a different branch to switch to.

    3. To compare two branches using git diff:

      git diff is a powerful command with many possible options. This solution will concentrate on the basics and the most common use-cases. No solution will be provided for VS Code here.

      • git diff branch1..branch2: This command compares the current state of branch1 with the current state of branch2.

      • git diff branch1...branch2: This command displays the changes that exist on branch2 but do not exist on branch1. It finds the common ancestor of branch1 and branch2 (where they started to diverge) and lists the changes made on branch2 from there.

Resolving divergences: Patching

  1. Read up on git reset and git reset --hard. What do these commands do? Experiment with them until you can confidently predict their results.

  2. Collect all changes from your various branches onto the main branch using each of the following techniques:

    1. ⭐️ format-patch, switch, am
    2. cherry-pick
  3. What happens if you apply the same patch multiple times, or cherry-pick the same commits multiple times?

  1. The git reset and git reset --hard commands are ways to undo changes. Both take a <commit> as a parameter, which has two possible formats:

    • (Absolute) Commit hash: Each commit has a hash. You can find the hash of a particular commit by using git log --oneline. The hash is the string of hexadecimal characters printed on the left (for example 78312cefd). (This is just the first few characters of the hash; git log without --oneline will give you the full hash, but the short one is sufficient to identify a commit.)

      Note that git log uses a pager by default: because there may be too many commits to fit on the screen, the output can be scrolled in the terminal (just like a webpage). Use and to scroll a single line, PgUp and PgDn to scroll multiple lines at a time, and q to quit.

      You can also turn paging off with git --no-pager log (note how the --no-pager option is before log, because it is an option for the git command, not the log subcommand). Be careful if you are working on a repository with a lot of commits.

    • (Relative) Offset from HEAD: HEAD is a reference to the last commit made on the current branch. HEAD~1 refers to the commit right before the last one, and so on for HEAD~2, HEAD~3, etc.

    Hence, a git reset command can look something like git reset 78312cefd or git reset HEAD~4.

    To learn more about git reset, including the --hard option, refer to the documentation.

    No detailed step-by-step solution will be provided for git reset. The only way to get comfortable with this command is to experiment by yourself. Make small commits, predict what a reset command would do, and then run the command, repeatedly, until you can confidently predict what git reset would do.

  2. Let us now collect changes from our branches onto main.

    1. Using format-patch, switch and am.

      1. First, git switch to the branch where the changes are that you want to bring to main.

      2. Run git format-patch main (replace main with the destination’s branch name if you used a different one). This command will generate one file per commit that is on your exercise branch but not on main.

      3. git switch back to your main branch.

      4. Use git am *.patch to apply every .patch file onto main. Because the files are numbered, the commits will be added in the right order. If you encounter conflicts, see the Conflicts exercises at the end of this page.

      5. Use rm *.patch to delete the .patch files once they have been applied, to avoid polluting your repository.

    2. Using cherry-pick.

      git cherry-pick is a command that allows us to choose specific commits from a branch and put them directly on another branch.

      1. Ensure you are on main (the destination branch).

      2. Use git log exercise-branch to get a list of commits on the other branch (replace exercise-branch with the actual name of your branch). Adding the parameter --oneline can make it more readable but will give you fewer details.

      3. For each commit you want to copy to main, run git cherry-pick <commit>. If you encounter conflicts, see Conflicts at the end of this page.

      4. Do a final git log --oneline to check that all your commits were added to main.

  3. Try applying a patch twice and cherry-picking the same commit twice. See what happens.

    • Re-applying a patch should tell you there are conflicts that need to be solved. Conflict solving is discussed in a later section. Run git am --abort to abort the operation of applying the patch.
    • Cherry-picking a commit twice will similarly detect conflicts, and prompt you to solve those conflicts. Run git cherry-pick --abort to abort the cherry-picking operation.

Resolving divergences: Merging

  1. Reset main to its initial state.

  2. Collect all changes from your various branches onto the main branch using each of the following techniques:

    1. ⭐️ Individual merges.
    2. A single octopus merge with all branches.
    3. A series of --ff-only merges, using rebase on each branch before merging it.
  3. What happens if you try to merge the same branch multiple times? What happens if you merge a branch, add commits to it, and merge it again?

  1. Run git switch main. Before resetting the branch, check that you still have all of your changes on the exercise branches. Once you ensured this, run git reset <commit> --hard, with <commit> being the hash of the first commit, where you added the scaffold for the exercises.

  2. Merging is another approach to collecting changes from diverged branches back into main.

    1. ⭐️ To perform an individual merge,

      1. Switch to your main branch.

      2. Run git merge exercise where exercise is the name of the branch you want to merge into main. If you encounter conflicts, see Conflicts at the end of this page.

      Repeat for each branch you want to merge into main.

      Note that in addition to the commits of the exercise branch you merged into main, each merge adds an additional merge commit, with a default message like “Merge branch ‘exercise’”.

    2. To perform a single “octopus” merge with all branches, the process is similar to individual merges, but grouped in a single command.

      1. Switch to your main branch. Reset it to before any merges were made.

      2. Run git merge exercise-1 exercise-2 exercise-3 where exercise-x are branches you want to merge into main. You can add as many as you like. If you encounter conflicts, see Conflicts at the end of this page.

      This will create a single merge operation for merging all of the specified branches into main. Note that a single merge commit will be created for the whole operation, not one per branch. By default, it will read something like “Merge branches ‘exercise-1’, ‘exercise-2’, and ‘exercise-3’”.

    3. --ff-only stands for Fast-Forward Only. It allows us to get rid of the merge commit, with the condition that the branch to merge is rebased on main.

      Understanding rebase

      git rebase <branch> attempts to “replay” the commits in your current branch on top of the current state of <branch>. If successful, your current branch’s history will be exactly the history of <branch>, followed by the replayed commits. The Git Book section about rebasing is a great resource for further reading (for the purpose of this exercise, only the “The Basic Rebase” section is relevant).

      Trying rebase in practice

      Let us try to rebase one of the exercise branches (say exercise) onto the main branch.

      1. Reset main to its initial state before the merges.
      2. Make a dummy commit that changes a file that you did not change in the exercise branches.
      3. Switch to the branch to merge, for example exercise.
      4. Run git rebase main. If you encounter conflicts, see Conflicts at the end of this page.
      5. Check that git log on your exercise branch shows your dummy commit from main, followed by the exercise-specific commits.

      Fast-forward merge (--ff-only)

      Now that exercise was rebased onto main, we can perform a fast-forward merge by “fast-forwarding” main to include the additional commits from exercise. No merge commit needs to be added because there are no diverging histories to join together. The Git Book section about rebasing is a great resource for further reading, with illustrations.

      1. Switch back to main.

      2. Run git merge --ff-only exercise.

      Can you see why a rebase ensures that a fast-forward–only merge will be successful? Repeat the above for all branches.

  3. Try it out and see for yourself!

History editing 🔥

  1. Find an exercise branch on which you had a suboptimal history: either a bad commit message, or multiple partial commits that would have been better as one, or a single commit that solves multiple parts of an exercise, etc.

  2. Use interactive rebasing (git rebase -i) to perform the following:

    • Split a single commit into multiple
    • Combine multiple consecutive commits into a single one
    • Reword a commit message
    • Reorder commits
  1. (No solution provided)

  2. Here is how to use interactive rebasing to fix suboptimal history in Git.

    • Split a single commit into multiple

      1. Start the interactive rebasing using git rebase -i HEAD~n. This will select the last n commits on the current branch for modification, and show you a list of those commits in a text editor with the keyword pick to the left of each. This is the plan for the rebase that you can modify before executing it. In an interactive rebase, to pick a commit is to apply it without any changes.

      2. For the commit you want to split into multiple commits, change pick to edit. Save and close the file.

      3. Go back to your Git terminal. You can now edit the commit in the terminal. The working directory reflects the state at that commit. Use git reset HEAD~0 to unstage the changes made in that commit, so that you can edit them.

      4. Modify the files, and use a combination of git add and git commit to create new commits with the modified files. Those commits will replace the commit you are editing.

      5. Once you are satisfied with your edits, use git rebase --continue to indicate this commit has been edited as you wished. If you had other modifications planned, the interactive rebasing will go to the next one, otherwise it will update the branch and terminate.

    • Combine multiple consecutive commits into a single one

      1. As above, start the interactive rebasing using git rebase -i HEAD~n.

      2. For the commits you want to squash, keep the first as a pick, but replace pick with squash for all the rest. To squash a commit is to make its changes part of the previous picked commit. If multiple commits are marked for squashing on top of a picked one, Git will squash the first, then treat the result as the base for squashing the second, and so on.

      3. Git will ask you to write a commit message for the commit that combines the commits you selected. Edit, save, and close the file.

      4. Once the rebase is successful, check the result using git log --oneline. The commits should have been squashed into a single one.

    • Reword a commit message

      1. As above, start the interactive rebasing using git rebase -i HEAD~n.

      2. For the commit you want to reword (rewrite its commit message), change pick to reword. Save and close the file.

      3. Git will open your configured editor to rewrite the commit message. Save and close the file.

      4. You can check using git log --oneline that your commit mesage was indeed changed.

    • Reorder commits

      1. As above, start the interactive rebasing using git rebase -i HEAD~n.

      2. Simply rearrange the lines in the editor to reorder the commits as desired.

      3. Save and close the file. Git will reapply the commits in the new order. Note that you might encounter conflicts when reordering commits (can you see why?).

      4. You can check using git log --oneline that the order of your commits was changed as desired.

    If a step is unsuccessful during an interactive rebase, Git will wait for you to fix the issue and then tell it to continue with git rebase --continue, or tell it to abort the whole rebase with git rebase --abort.

Conflicts

  1. Make sure that you have configured Git to use the diff3 style (git config --global merge.conflictStyle diff3).

  2. ⭐️ Pick a branch containing a complex exercise, create two branches from it, and make different changes to the same piece of code on both branches. Merge one branch into main, then the second. What happens during the second merge? Resolve the conflict and commit the merge.

  3. Reset main, then use cherry-picks to move both commits to it. Resolve the conflict again and finish the cherry-pick.

  4. Reset main, then rebase one of the two branches onto the other. Resolve the conflict again and finish the rebase.

  1. See instructions.

  2. To create two branches and merge them into main, follow those steps:

    1. Find such a branch and switch to it.

    2. Run git branch temp-1 and git branch temp-2, or whatever you want to call the two derived branches. Because you create them while being on the branch you selected, they will be based on that branch.

    3. Select one piece of code (does not need to be big) in the code. Change it differently on each branch. For example, you might rename a variable myVal1 on temp-1 and myVal2 on temp-2.

    4. Merge temp-1 into main. See the section about merging for a reminder of how to do this.

    5. Merge temp-2 into main.

    6. When trying to merge temp-2 into main, you should have conflicts: Git will output something like the following.

      Auto-merging [filename]
      CONFLICT (add/add): Merge conflict in [filename]
      Automatic merge failed; fix conflicts and then commit the result.
      
    7. Open [filename] with an editor. You should see something of the form

      <<<<<<< HEAD
      This is what you merged from temp-1
      ||||||| 6068cc3
      This is the common ancestor, what was there before temp-1 and temp-2
      =======
      This is what you are trying to merge from temp-2
      >>>>>>> temp-2
      

      This diff shows you three things:

      • Common ancestor: Between ||||||| and ======= is the original state before you modified it in temp-1 and temp-2.
      • HEAD (“ours”): Between <<<<<<< and ||||||| is what you merged from temp-1. This is the current state of the main branch where you are. In Git terminology, we say those changes are ours (they are on our current branch).
      • Incoming (“theirs”): Between ======= and >>>>>>> are the changes on temp-2 that are now being merged into main, but are conflicting with the changes already merged from temp-1. In Git terminology, these changes are called theirs because they are often someone else’s changes that we are trying to merge into ours.

      To resolve those conflicts, you have to decide yourself what you want the result of this merge to contain. For example, if you want to keep the version of temp-2, modify the file so that it contains

      This is what you are trying to merge from temp-2
      

      If you want to do a mix, modify the file so that it contains

      This is what you merged from temp-1 but now you merged temp-2 as well
      

      You can have several conflicts in one or more files. Resolve all of them in the above manner and go back to your terminal. Resolving a conflict means that there are no more >>>>>>>, ||||||| or =======, and that the file only contains exactly what you want.

    8. Once the conflicts all have been solved in the file, git add it. Repeat the process for all the files that contain conflicts. Once they are all added, use git commit to finish the merge.

  3. To do the same process with cherry-pick, follow these steps:

    1. If you already merged both branches into main, cherry-picking commits wouldn’t make sense, so we first reset main to before the merge happened.

    2. Then cherry-pick the commit you did on temp-1 onto main (go back to the cherry-pick exercise if you are unsure about how to do this).

    3. Cherry-pick the commit you did on temp-2 onto main. A conflict should arise. The conflict will be very similar to what you encountered with git merge above. Refer to the explanations there about how to solve the conflicts.

    4. Once all of the conflicts have been resolved and all the resolved conflicted files added back to Git (staged), commit them. The cherry-picking is now done.

  4. To rebase the two conflicting branches one onto another:

    1. Switch to one of the two conflicting branches. For example, git switch temp-2.

    2. Try rebasing it onto the other conflicting branch, for example git rebase temp-1.

    3. Again, conflicts should arise, and again, very similar to, if not the same as, what you have seen in the two previous exercises.

    4. Solve the conflicts in the same manner, and commit everything to finish the rebase.