This course will be delivered in blended learning mode (i.e., a mix of online and F2F activities) this semester.

Week 3 [Mon, Aug 25th] - Topics

Detailed Table of Contents



Guidance for the item(s) below:

Let's learn about a few more Git techniques, starting with branching. Although these techniques are not really needed for the iP, we require you to use them in the iP so that you have more time to practice them before they are really needed in the tP.

[W3.1] RCS: Branching

W3.1a

Git Learning Trail → Tour 6: Branching Locally

Tour 6: Branching Locally

Target Usage: To make use of multiple timelines of work in a local repository.

Motivation: At times, you need to do multiple parallel changes to files (e.g., to try two alternative implementations of the same feature).

Lesson plan:

To work in parallel timelines, you can use Git branches.

   T6L1. Creating Branches covers that part.

Most work done in branches eventually gets merged together.

   T6L2. Merging Branches covers that part.

When merging branches, you need to guide Git on how to resolve conflicting changes in different branches.

   T6L3. Resolving Merge Conflicts covers that part.

Branches can be renamed, for example, to fix a mistake in the branch name.

   T6L4. Renaming Branches covers that part.

Branches can be deleted to get rid of them when they are no longer needed.

   T6L5. Deleting Branches covers that part.

T6L1. Creating Branches


To work in parallel timelines, you can use Git branches.

This lesson covers that part.

Git branches let you develop multiple versions of your work in parallel — effectively creating diverged timelines of your repository’s history. For example, one team member can create a new branch to experiment with a change, while the rest of the team continues working on another branch. Branches can have meaningful names, such as master, release, or draft.

A Git branch is simply a ref (a named label) that points to a commit and automatically moves forward as you add new commits to that branch. As you’ve seen before, the HEAD ref indicates which branch you’re currently working on, by pointing to the corresponding branch ref.
When you add a commit, it goes into the branch you are currently on, and the branch ref (together with the HEAD ref) moves to the new commit.

Git creates a branch named master by default (Git can be configured to use a different name e.g., main).

Given below is an illustration of how branch refs move as branches evolve. Refer to the text below it for explanations of each stage.

  • There is only one branch (i.e., master) and there is only one commit on it. The HEAD ref is pointing to the master branch (as we are currently on that branch).
  • A new commit has been added. The master and the HEAD refs have moved to the new commit.
  • A new branch fix1 has been added. The repo has switched to the new branch too (hence, the HEAD ref is attached to the fix1 branch).
  • A new commit (c) has been added. The current branch ref fix1 moves to the new commit, together with the HEAD ref.
  • The repo has switched back to the master branch. Hence, the HEAD has moved back to master branch's .
    At this point, the repo's working directory reflects the code at commit b (not c).
  • A new commit (d) has been added. The master and the HEAD refs have moved to that commit.
  • The repo has switched back to the fix1 branch and added a new commit e to it. Note how the branch ref fix1 (together with HEAD) has moved to the new commit e while the branch ref master still points to d.

Note that appearance of the revision graph (colors, positioning, orientation etc.) varies based on the Git client you use, and might not match the exact diagrams given above.

HANDS-ON: Work on parallel branches

Preparation Fork the samplerepo-things repo, and clone it onto your computer.

1 Observe that you are on the branch called master.

$ git status
on branch master


2 Start a branch named feature1 and switch to the new branch.

You can use the branch command to create a new branch and the checkout command to switch to a specific branch.

$ git branch feature1
$ git checkout feature1

One-step shortcut to create a branch and switch to it at the same time:

$ git checkout –b feature1

The new switch command

Git recently introduced a switch command that you can use instead of the checkout command given above.

To create a new branch and switch to it:

$ git branch feature1
$ git switch feature1

One-step shortcut (by using -c or --create flag):

$ git switch –c feature1

Click on the Branch button on the main menu. In the next dialog, enter the branch name and click Create Branch.

Note how the feature1 is indicated as the current branch (reason: Sourcetree automatically switches to the new branch when you create a new branch, if the Checkout New Branch was selected in the previous dialog).


3 Create some commits in the new branch, as follows.

  • Add a file named numbers.txt, stage it, commit it.
  • Observe how commits you add while on feature branch will becomes part of that branch.
    Observe how the master ref and the HEAD ref move to the new commit.

As before, you can use the git log --oneline --decorate command for this.


  • At times, the HEAD ref of the local repo is represented as in Sourcetree, as illustrated in the screenshot below .

  • The HEAD ref is not shown in the UI if it is already pointing at the active branch.


  • Add some texts to numbers.txt, stage the changes, and commit it. This commit too will be added to the feature1 branch.

4 Switch to the master branch. Note how the changes you made in the feature1 branch are no longer in the working directory.

$ git switch master

Double-click the master branch.

Revisiting master vs origin/master

In the screenshot above, you see a master ref and a origin/master ref for the same commit. The former identifies the of the local master branch while the latter identifies the tip of the master branch at the remote repo named origin. The fact that both refs point to the same commit means the local master branch and its remote counterpart are with each other. Similarly, origin/HEAD ref appearing against the same commit indicates that of the remote repo is pointing to this commit as well.


5 Add a commit to the master branch. Let’s imagine it’s a bug fix.
To keep things simple for the time being, this commit should not involve the numbers.txt file that you changed in the feature1 branch. Of course, this is easily done, as the numbers.txt file you added in the feature branch is not even visible when you are in the master branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "[feature1] f2"
    checkout master
    commit id: "[HEAD → master] m3"
    checkout feature1

6 Switch between the two branches and see how the working directory changes accordingly. That is, now you have two parallel timelines that you can freely switch between.

done!

You can also start a branch from an earlier commit, instead of the latest commit in the current branch. For that, simply check out the commit you wish to start from.

HANDS-ON: Start a branch from an earlier commit

In the samplerepo-things repo that you used above, let's create a new branch that starts from the same commit the feature1 branch started from. Let's pretend this branch will contain an alternative version of the content we added in the feature1 branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    branch feature1-alt
    checkout feature1
    commit id: "f1"
    commit id: "[feature1] f2"
    checkout master
    commit id: "[HEAD → master] m3"
    checkout feature1-alt
    commit id: "[HEAD → feature1-alt] a1"

Avoid this rookie mistake!

Always remember to switch back to the master branch before creating a new branch. If not, your new branch will be created on top of the current branch.

  1. Switch to the master branch.
  2. Checkout the commit that is at which the feature1 branch diverged from the master branch (e.g. git checkout HEAD~1). This will create a detached HEAD.
  3. Create a new branch called feature1-alt. The HEAD will now point to this new branch (i.e., no longer 'detached').
  4. Add a commit on the new branch.

PRO-TIP: Creating a branch based on another branch in one shot

Suppose you are currently on branch b2 and you want to create a new branch b3 that starts from b1. Normally, you can do that in two steps:

git switch b1     # switch to the intended base branch first
git switch -c b3  # create the new branch and switch to it

This can be done in one shot using the git switch -c <new-branch> <base-branch> command:

git switch -c b3 b1

done!

EXERCISE: side-track



T6L2. Merging Branches


Most work done in branches eventually gets merged together.

This lesson covers that part.

Merging combines the changes from one branch into another, bringing their diverged timelines back together.

When you merge, Git looks at the two branches and figures out how their histories have diverged since their merge base (i.e., the most recent common ancestor commit of two branches). It then applies the changes from the other branch onto your current branch, creating a new commit. The new commit created when merging is called a merge commit — it records the result of combining both sets of changes.

Given below is an illustration of how such a merge looks like in the revision graph:

  • We are on the fix1 branch (as indicated by HEAD).
  • We have switched to the master branch (thus, HEAD is now pointing to master ref).
  • The fix1 branch has been merged into the master branch, creating a merge commit f. The repo is still on the master branch.

A merge commit has two parent commits e.g., in the above example, the merge commit f has both d and e as parent commits. The parent commit on the receiving branch is considered the first parent and the other is considered the second parent e.g., in the example above, fix1 branch is being merged into the master branch (i.e., the receiving branch) -- accordingly, d is the first parent and e is the second parent.

HANDS-ON: Merge a branch (with a merge commit)

Preparation We continue with the samplerepo-things repo from earlier, which should look like the following. Note that we are ignoring the feature1-alt branch, for simplicity.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "[feature1] f2"
    checkout master
    commit id: "[HEAD → master] m3"
    checkout feature1

1 Switch back to the feature1 branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "[HEAD → feature1] f2"
    checkout master
    commit id: "[master] m3"
    checkout feature1

2 Merge the master branch to the feature1 branch, giving an end-result like the following. Also note how Git has created a merge commit (shown as mc1 in the diagram below).

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout master
    commit id: "[master] m3"
    checkout feature1
    merge master id: "[HEAD → feature1] mc1"
$ git merge master

Right-click on the master branch and choose merge master into the current branch. Click OK in the next dialog.
The revision graph should look like this now (colours and line alignment might vary but the graph structure should be the same):


Observe how the changes you made in the master branch (i.e., the imaginary bug fix in m3) is now available even when you are in the feature1 branch.
Furthermore, observe (e.g., git show HEAD) how the merge commit contains the sum of changes done in commits m3, f1, and f2.

3 Add another commit to the feature1 branch, in which you do some further changes to the numbers.txt.
Switch to the master branch and add one more commit.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout master
    commit id: "m3"
    checkout feature1
    merge master id: "mc1"
    commit id: "[feature1] f3"
    checkout master
    commit id: "[HEAD → master] m4"

4 Merge feature1 to the master branch, giving an end-result like this:

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout master
    commit id: "m3"
    checkout feature1
    merge master id: "mc1"
    commit id: "[feature1] f3"
    checkout master
    commit id: "m4"
    merge feature1 id: "[HEAD → master] mc2"
git merge feature1

Right-click on the feature1 branch and choose Merge.... The resulting revision graph should look like this:


Now, any changes you made in feature1 branch are available in the master branch.

done!

When the branch you're merging into hasn't diverged — meaning it hasn't had any new commits since the merge base — Git simply moves the branch pointer forward to include all the new commits, keeping the history clean and linear. This is called a fast-forward merge because Git simply "fast-forwards" the branch pointer to the tip of the other branch. The result looks as if all the changes had been made directly on one branch, without any branching at all.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "[HEAD → master] m2"
    branch bug-fix
    commit id: "b1"
    commit id: "[bug-fix] b2"
    checkout master


[merge bug-fix]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    commit id: "b1"
    commit id: "[HEAD → master][bug-fix] b2"
    checkout master

In the example above, the master branch has not changed since the merge base (i.e., m2). Hence, merging the branch bug-fix onto master can be done by fast-forwarding the master branch ref to the tip of the bug-fix branch (i.e., b2).

HANDS-ON: Do a fast-forward merge

Preparation Let's continue with the same samplerepo-things repo we used above, and do a fast-forward merge this time.

1 Create a new branch called add-countries, and some commits to it as follows:
Switch to the new branch, add a file named countries.txt, stage it, and commit it.
Do some changes to countries.txt, and commit those changes.
You should have something like this now:

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "[master] mc2"
    branch add-countries
    commit id: "a1"
    commit id: "[HEAD → add-countries] a2"

2 Go back to the master branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "[HEAD → master] mc2"
    branch add-countries
    commit id: "a1"
    commit id: "add-countries] a2"

3 Merge the add-countries branch onto the master branch. Observe that there is no merge commit. The master branch ref (and the HEAD ref along with it) moved to the tip of the add-countries branch (i.e., a2) and both branches now point to a2.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master (and add-countries)'}} }%%
    commit id: "mc2"
    commit id: "a1"
    commit id: "[HEAD → master][add-countries] a2"

done!

It is possible to force Git to create a merge commit even if fast forwarding is possible. This is useful if you prefer the revision graph to visually show when each branch was merged to the main timeline.

To prevent Git from fast-forwarding, use the --no-ff switch when merging. Example:

git merge --no-ff add-countries

Windows: Tick the box shown below when you merge a branch:


Mac:

Trigger the branch operation using the following menu button:

Sourcetree top menu

In the next dialog, tick the following option:

To permanently prevent fast-forwarding:

  1. Go to Sourcetree Settings.
  2. Navigate to the Git section.
  3. Tick the box Do not fast-forward when merging, always create commit.

A squash merge combines all the changes from a branch into a single commit on the receiving branch, without preserving the full commit history of the branch being merged. This is especially useful when the feature branch contains many small or experimental commits that would clutter the main branch’s history. By squashing, you retain the final state of the changes while presenting them as one cohesive unit, making the project history easier to read and manage. It also helps maintain a linear, simplified commit log on the main branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "[HEAD → master] m1"
    branch feature
    checkout feature
    commit id: "f1"
    commit id: "[feature] f2"


[squash merge...]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    commit id: "[feature] f2"
    checkout master
    commit id: "[HEAD → master] s1 (same as f1+f2)" type: HIGHLIGHT

In the example above, the branch feature has been squash merged onto the master branch, creating a single 'squashed' commit s1 that combines all the commits in feature branch.

EXERCISE: branch-bender


DETOUR: Undoing a Merge

  1. Ensure you are in the .
  2. Do a hard reset of that branch to the commit that would be the tip of that branch had you not done the offending merge i.e., rewind that branch to the state it was in before the merge.

In the example below, you merged master to feature1.

If you want to undo that merge,

  1. Ensure you are in the feature1 branch (because that's the branch that received the merge).
  2. Reset the feature1 branch to the commit highlighted (in yellow) in the screenshot above (because that was the tip of the feature1 branch before you merged the master branch to it).

DETOUR: Comparing Branches

Comparing branches in Git is useful when you want to understand how two lines of development differ — for example, before merging a branch, you might want to review what changes it introduces to the main branch.

Here are two ways to compare two branches:

  • Double-dot notation git diff branchA..branchB: Changes based on commits in branchB but not in branchA. This is the one used more commonly.
  • Triple-dot notation git diff branchA...branchB: This shows changes in all the commits that are reachable by either of two references but not by both of them.
    i.e., commits unique to branchA or branchB.

DETOUR: Doing a Squash Merge

To do a squash merge, you can use the --squash switch. It will prepare the squashed merge commit but will stop short of actually finalising the commit.

git merge --squash feature-1
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

At that point, you can go ahead and make the commit yourself, with the commit message you want.



T6L3. Resolving Merge Conflicts


When merging branches, you need to guide Git on how to resolve conflicting changes in different branches.

This lesson covers that part.

A merge conflict happens when Git can't automatically combine changes from two branches because the same parts of a file were modified differently in each branch. When this happens, Git pauses the merge and marks the conflicting sections in the affected files so you can resolve them yourself. Once you've reviewed and fixed the conflicts, you can tell Git they're resolved and complete the merge.

More generally, a conflict occurs when Git cannot automatically reconcile different changes made to the same part of a file -- branch merge conflicts is just one example.

HANDS-ON: Resolve merge conflict

Target To simulate a merge conflict and use it to learn how to resolve merge conflicts.

Preparation You can use any repo with at least one commit in the master branch.

1 Start a branch named fix1 in the repo. Create a commit that adds a line with some text to one of the files.

2 Switch back to master branch. Create a commit with a conflicting change i.e., it adds a line with some different text in the exact location the previous line was added.

3 Try to merge the fix1 branch onto the master branch. Git will pause mid-way during the merge and report a merge conflict. If you open the conflicted file, you will see something like this:

COLORS
------
blue
<<<<<< HEAD
black
=======
green
>>>>>> fix1
red
white

4 Observe how the conflicted part is marked between a line starting with <<<<<< and a line starting with >>>>>>, separated by another line starting with =======.

Highlighted below is the conflicting part that is coming from the master branch:

blue
<<<<<< HEAD
black
=======
green
>>>>>> fix1
red

This is the conflicting part that is coming from the fix1 branch:

blue
<<<<<< HEAD
black
=======
green
>>>>>> fix1
red

5 Resolve the conflict by editing the file. Let us assume you want to keep both lines in the merged version. You can modify the file to be like this:

COLORS
------
blue
black
green
red
white

6 Stage the changes, and commit. You have now successfully resolved the merge conflict.

done!

EXERCISE: conflict-mediator



T6L4. Renaming Branches


Branches can be renamed, for example, to fix a mistake in the branch name.

This lesson covers that part.

Local branches can be renamed easily. Renaming a branch simply changes the branch reference (i.e., the name used to identify the branch) — it is just a cosmetic change.

HANDS-ON: Rename local branches

Preparation First, create the repo samplerepo-books for this hands-on practical, by running the following commands in your terminal.

mkdir samplerepo-books
cd samplerepo-books
git init
echo "Horror Stories" >> horror.txt
git add .
git commit -m "Add horror.txt"
git switch -c textbooks
echo "Textbooks" >> textbooks.txt
git add .
git commit -m "Add textbooks.txt"
git switch master
git switch -c fantasy
echo "Fantasy Books" >> fantasy.txt
git add .
git commit -m "Add fantasy.txt"
git switch master
git merge --no-ff -m "Merge branch textbooks" textbooks

The above should give you a repo similar to the revision graph given below, on the left.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch textbooks
    checkout textbooks
    commit id: "[textbooks] t1"
    checkout master
    branch fantasy
    checkout fantasy
    commit id: "[fantasy] f1"
    checkout master
    merge textbooks id: "[HEAD → master] mc1"


[rename branches]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch study-books
    checkout study-books
    commit id: "[study-books] t1"
    checkout master
    branch fantasy-books
    checkout fantasy-books
    commit id: "[fantasy-books] f1"
    checkout master
    merge study-books id: "[HEAD → master] mc1"

Target Rename the fantasy branch to fantasy-books. Similarly, rename textbooks branch to study-books. The outcome should be similar to the revision graph above, on the right.

steps:

To rename a branch, use the git branch -m <current-name> <new-name> command (-m stands for 'move'):

git branch -m fantasy fantasy-books
git branch -m textbooks study-books
git log --oneline --decorate --graph --all  # verify the changes
*   443132a (HEAD -> master) Merge branch textbooks
|\
| * 4969163 (study-books) Add textbooks.txt
|/
| * 0586ee1 (fantasy-books) Add fantasy.txt
|/
* 7f28f0e Add horror.txt

Note these additional switches to the log command:

  • --all: Shows all branches, not just the current branch.
  • --graph: Shows a graph-like visualisation (notice how * is used to indicate a commit, and branches are indicated using vertical lines).

Right-click on the branch name and choose Rename.... Provide the new branch name in the next dialog.


done!

SIDEBAR: Branch naming conventions

Branch names can contain lowercase letters, numbers, /, dashes (-), underscores (_), and dots (.). You can use uppercase letters too, but many teams avoid them for consistency.

A common branch naming convention is to prefix it with <category>/. Some examples:

  • feature/login-form — for new features (origin/feature/login-form could be the matching remote-tracking branch)
  • bugfix/profile-photo — for fixing bugs
  • hotfix/payment-crash — for urgent production fixes
  • release/2.0 — for prepping a release
  • experiment/ai-chatbot — for “just trying stuff”

Although forward-slash (/) in the prefix doesn't mean folders, some tools treat it kind of like a path so you can group related branches when you run git branch. Shown below is an example of how Sourcetree groups branches with the same prefix.

EXERCISE: branch-rename



T6L5. Deleting Branches


Branches can be deleted to get rid of them when they are no longer needed.

This lesson covers that part.

Deleting a branch deletes the corresponding branch ref from the revision history (it does not delete any commits). The impact of the loss of the branch ref depends on whether the branch has been merged.

When you delete a branch that has been merged, the commits of the branch will still exist in the history and will be safe. Only the branch ref is lost.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch bug-fix
    checkout bug-fix
    commit id: "[bug-fix] b1"
    checkout master
    merge bug-fix id: "[HEAD → master] mc1"


[delete branch bug-fix]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch _
    checkout _
    commit id: "b1"
    checkout master
    merge _ id: "[HEAD → master] mc1"

In the above example, the only impact of the deletion is the loss of the branch ref bug-fix. All commits remain reachable (via the master branch), and there is no other impact on the revision history.

In fact, some prefer to delete the branch soon after merging it, to reduce branch references cluttering up the revision history.

When you delete a branch that has not been merged, the loss of the branch ref can render some commits unreachable (unless you know their commit IDs or they are reachable through other refs), putting them at risk of being lost eventually.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "[HEAD → master] m1"
    branch bug-fix
    checkout bug-fix
    commit id: "[bug-fix] b1"
    checkout master


[delete branch bug-fix]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "[HEAD → master] m1"
    branch _
    checkout _
    commit id: "b1"
    checkout master

In the above example, the commit b1 is no longer reachable, unless we know its commit ID (i.e., the SHA).

SIDEBAR: What makes a commit 'unreachable'?

Recall that a commit only has a pointer to its parent commit (not its descendent commits).

A commit is considered reachable if you can get to it by starting at a branch, tag, or other ref and walking backward through its parent commits. This is the normal state for commits — they are part of the visible history of a branch or tag.

When no branch, tag, or ref points to a commit (directly or indirectly), it becomes unreachable. This often happens when you delete a branch or rewrite history (e.g., with reset or rebase), leaving some commits "orphaned" (or "dangling") without a ref pointing to them.

In the example below, C4 is unreachable (i.e., cannot be reached by starting at any of the three refs: v1.0 or master or HEAD), but the other three are all reachable.

C4unreachable!
C3 v1.0
C2 masterHEAD
C1

Unreachable commits are not deleted immediately — Git keeps them for a while before cleaning them up. By default, Git retains unreachable commits for at least 30 days, during which they can still be recovered if you know their SHA. After that, they will be garbage-collected, and will be lost for good.

HANDS-ON: Delete branches

Preparation First, create the repo samplerepo-books-2 for this hands-on practical, by running the following commands in your terminal.

mkdir samplerepo-books-2
cd samplerepo-books-2
git init
echo "Horror Stories" >> horror.txt
git add .
git commit -m "Add horror.txt"
git switch -c textbooks
echo "Textbooks" >> textbooks.txt
git add .
git commit -m "Add textbooks.txt"
git switch master
git switch -c fantasy
echo "Fantasy Books" >> fantasy.txt
git add .
git commit -m "Add fantasy.txt"
git switch master
git merge --no-ff -m "Merge branch textbooks" textbooks

1 Delete the (the merged) textbooks branch.

Use the git branch -d <branch> command to delete a local branch 'safely' -- this command will fail if the branch has unmerged changes.

git branch -d textbooks
git log --oneline --decorate --graph --all  # check the current revision graph
*   443132a (HEAD -> master) Merge branch textbooks
|\
| * 4969163 Add textbooks.txt
|/
| * 0586ee1 (fantasy) Add fantasy.txt
|/
* 7f28f0e Add horror.txt

Right-click on the branch name and choose Delete <branch>:

In the next dialog, click OK:


Observe that all commits remain. The only missing thing is the textbook ref.

2 Make a copy of the SHA of the tip of the (unmerged) fantasy branch.

3 Delete the fantasy branch.

Attempt to delete the branch. It should fail, as shown below:

git branch -d fantasy
error: the branch 'fantasy' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D fantasy'

As also hinted by the error message, you can replace the -d with -D to 'force' the deletion.

git branch -D fantasy

Now, check the revision graph:

git log --oneline --decorate --graph --all
*   443132a (HEAD -> master) Merge branch textbooks
|\
| * 4969163 Add textbooks.txt
|/
* 7f28f0e Add horror.txt

Attempt to delete the branch as you did before. It will fail because the branch has unmerged commits.

Try again but this time, tick the Force delete option, which will force Git to delete the unmerged branch:


Observe how the branch ref fantasy is gone, together with any unmerged commits on it.

4 Attempt to view the 'unreachable' commit whose SHA you noted in step 2.

e.g., git show 32b34fb (use the SHA you copied earlier)

Observe how the commit still exists and still is reachable using the commit ID, although it is not reachable by other means, and not visible in the revision graph.

done!

EXERCISE: branch-delete



At this point: Now you can create, maintain, and merge multiple parallel branches in a local repo. This tour covered only the basic use of Git branches. More advanced usage will be covered in other tours.

What's next: Tour 7: Keeping Branches in Sync


W3.1b

Git Learning Trail → Tour 7: Keeping Branches in Sync

Tour 7: Keeping Branches in Sync

Target Usage: To keep branches in a local repository synchronised with each other, as needed.

Motivation: While working on one branch, you might want to have access to changes introduced in another branch (e.g., to take advantage of a bug fix introduced in another branch).

Lesson plan:

Merging is one way to keep one branch synchronised with another.

   T7L1. Merging to Sync Branches covers that part.

Rebasing is another way to synchronise one branch with another.

   T7L2. Rebasing to Sync Branches covers that part.

Cherry-picking is a Git operation that copies over a specific commit from one branch to another.

   T7L3. Copying Specific Commits covers that part.

T7L1. Merging to Sync Branches


Merging is one way to keep one branch synchronised with another.

This lesson covers that part.

When working in parallel branches, you’ll often need to sync (short for synchronise) one branch with another. For example, while developing a feature in one branch, you might want to bring in a recent bug fix from another branch that your branch doesn’t yet have.

The simplest way to sync branches is to merge — that is, to sync a branch b1 with changes from another branch b2, you merge b2 into b1. In fact, you can merge them periodically to keep one branch up to date with the other.

gitGraph
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch bug-fix
    branch feature
    commit id: "f1"
    checkout master
    checkout bug-fix
    commit id: "b1"
    checkout master
    merge bug-fix
    checkout feature
    merge master id: "mc1"
    commit id: "f2"
    checkout master
    commit id: "m2"
    checkout feature
    merge master id: "mc2"
    checkout master
    commit id: "m3"
    checkout feature
    commit id: "[feature] f3"
    checkout master
    commit id: "[HEAD → master] m4"

In the example above, you can see how the feature branch is merging the master branch periodically to keep itself in sync with the changes being introduced to the master branch.


T7L2. Rebasing to Sync Branches


Rebasing is another way to synchronise one branch with another.

This lesson covers that part.

Rebasing is another way to synchronise one branch with another, while keeping the history cleaner and more linear. Instead of creating a merge commit to combine the branches, rebasing moves the entire sequence of commits from your branch and "replays" them on top of another branch. This effectively moves the base of your branch to the tip of the other branch (i.e., it 're-bases' it — hence the name), as if you had started your work from there in the first place.

Rebasing is especially useful when you want to update your branch with the latest changes from a main branch, but you prefer an uncluttered history with fewer merge commits.

Suppose we have the following revision graph, and we want to sync the feature branch with master, so that changes in commit m2 become visible to the feature branch.

gitGraph
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    checkout master
    commit id: "[master] m2"
    checkout feature
    commit id: "[HEAD → feature] f2"

If we merge the master branch to the feature branch as given below, m2 becomes visible to the feature branch. However, it creates a merge commit.

gitGraph
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    checkout master
    commit id: "[master] m2"
    checkout feature
    commit id: "f2"
    merge master id: "[HEAD → feature] mc1"

Instead of merging, if we rebased the feature branch on the master branch, we would get the following.

gitGraph
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    checkout master
    commit id: "[branch: master] m2"
    branch feature
    checkout feature
    commit id: "f1a"
    commit id: "[HEAD → feature] f2a"

Note how the rebasing changed the base of the feature branch from m1 to m2. As a result, changes done in m2 are now visible to the feature branch. But there is no merge commit, and the revision graph is simpler.

Also note how the first commit in the feature branch, previously shown as f1, is now shown as f1a after the rebase. Although both commits contain the same changes, other details -- such as the parent commit -- are different, making them two distinct Git objects with different SHA values. Similarly, f2 and f2a are also different. Thus, the history of the entire feature branch has changed after the rebase.

Because rebasing rewrites the commit history of your branch, you should avoid rebasing branches that you’ve already published, and are potentially used by others -- rewriting published history can cause confusion and conflicts for those using the previous version of the commits.


T7L3. Copying Specific Commits


Cherry-picking is a Git operation that copies over a specific commit from one branch to another.

This lesson covers that part.

Cherry-picking is another way to synchronise branches, by applying specific commits from one branch onto another.

Unlike merging or rebasing — which bring over all changes since the branches diverged — cherry-picking lets you choose individual commits and apply just those, one at a time, to your current branch. This is useful when you want to bring over a bug fix or a small feature from another branch without merging the entire branch history.

Because cherry-picking copies only the chosen commits, it creates new commits on your branch with the same changes but different SHA values.

Suppose we have the following revision graph, and we want to bring over the changes introduced in m3 (in the master branch) onto the feature branch.

gitGraph
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    checkout master
    commit id: "m2"
    commit id: "m3" type: HIGHLIGHT
    commit id: "[master] m4"
    checkout feature
    commit id: "[HEAD → feature] f2"

After cherry-picking m3 onto the feature branch, the revision graph should look like the following:

gitGraph
%%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    checkout master
    commit id: "m2"
    commit id: "m3" type: HIGHLIGHT
    commit id: "[master] m4"
    checkout feature
    commit id: "f3"
    commit id: "[HEAD → feature] m3a" type: HIGHLIGHT

Note how it makes the changes done in m3 available (from now on) in the feature branch, with minimal changes to the revision graph. Also note that the new commit m3a contains the same changes as m3, but it will be a different Git object with a different SHA value.

Cherry-picking is another Git operation that can result in conflicts i.e., if the changes in the cherry-picked commit conflict with the changes in the receiving branch.


At this point: You should now be able to bring changes from one branch to another in your local repository.

What's next: Tour 8: Working with Remote Branches


W3.1c

Git Learning Trail → Tour 8: Working with Remote Branches

Tour 8: Working with Remote Branches

Target Usage: To synchronise branches in the local repo with a remote repo's branches.

Motivation: It is useful to be able to have another copy of branches in a remote repo.

Lesson plan:

Local branches can be replicated in a remote.

   T8L1. Pushing Branches to a Remote covers that part.

Branches in a remote can be replicated in the local repo, and maintained in sync with each other.

   T8L2. Pulling Branches from a Remote covers that part.

Often, you'll need to delete a branch in a remote repo after it has served its purpose.

   T8L3. Deleting Branches from a Remote covers that part.

Occasionally, you might need to rename a branch in a remote repo.

   T8L4. Renaming Branches in a Remote covers that part.

T8L1. Pushing Branches to a Remote


Local branches can be replicated in a remote.

This lesson covers that part.

Pushing a copy of local branches to the corresponding remote repo makes those branches available remotely.

In a previous lesson, we saw how to push the default branch to a remote repository and have Git set up tracking between the local and remote branches using a remote-tracking reference. Pushing any other local branch to a remote works the same way as pushing the default branch — you simply specify the target branch instead of the default branch. Pushing any new commits in any local branch to a corresponding remote branch is done similarly as well.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch bug-fix
    checkout master
    commit id: "[origin/master][HEAD → master] m2"
    checkout bug-fix
    commit id: "[bug-fix] b1"
    checkout master

[bug-fix branch does not exist in the remote origin]


gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    branch bug-fix
    checkout master
    commit id: "[origin/master][HEAD → master] m2"
    checkout bug-fix
    commit id: "[origin/bug-fix][bug-fix] b1"
    checkout master

[after pushing bug-fix branch to origin,
and setting up a remote-tracking branch]

HANDS-ON: Push local branches to remote

Preparation Fork the samplerepo-company to your GitHub account. When doing so, un-tick the Copy the master branch only option.
After forking, go to the fork and ensure both branches (master, and track-sales) are in there.

Clone the fork to your computer. It should look something like this:

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch track-sales
    checkout track-sales
    commit id: "[origin/track-sales] s1"
    checkout master
    commit id: "[origin/master][origin/HEAD][HEAD → master] m3"

The origin/HEAD remote-tracking ref indicates where the HEAD ref is in the remote origin.

1 Create a new branch called hiring, and add a commit to that branch. The commit can contain any changes you want.

Here are the commands you can run in the terminal to do this step in one shot:

git switch -c hiring
echo "Receptionist: Pam" >> employees.txt
git commit -am "Add Pam to employees.txt"
gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'master'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch track-sales
    checkout track-sales
    commit id: "[origin/track-sales] s1"
    checkout master
    commit id: "[origin/master][origin/HEAD][master] m3"
    branch hiring
    checkout hiring
    commit id: "[HEAD → hiring] h1"

The resulting revision graph should look like the one above.

2 Push the hiring branch to the remote.

You can use the usual git push <remote> -u <branch> command to push the branch to the remote, and set up a remote-tracking branch at the same time.

git push origin -u hiring


3 Verify that the branch has been pushed to the remote by visiting the fork on GitHub, and looking for the origin/hiring remote-tracking ref in the local repo.

done!


T8L2. Pulling Branches from a Remote


Branches in a remote can be replicated in the local repo, and maintained in sync with each other.

This lesson covers that part.

Sometimes we need to create a local copy of a branch from a remote repository, make further changes to it, and keep it synchronised with the remote branch. Let's explore how to handle this in a few common use cases:

Use case 1: Working with branches that already existed in the remote repo when you cloned it to your computer.

When you clone a repository,

  1. Git checks out the default branch. You can start working on this branch immediately. This branch is tracking the default branch in the remote, which means you can easily synchronise changes in this branch with the remote by pulling and pushing.
  2. Git also fetches all the other branches from the remote. These other branches are not immediately available as local branches, but they are visible as remote-tracking branches.
    You can think of remote-tracking branches as read-only references to the state of those branches in the remote repository at the time of cloning. They allow you to see what work has been done on those branches without yet making local copies of them.
    To work on one of these branches, you can create a new local branch based on the remote-tracking branch. Once you do this, your local branch will usually be configured to track the corresponding branch on the remote, so you can easily synchronise your work later.
HANDS-ON: Work with a branch that existed in the remote

Preparation Use the same samplerepo-company repo you used in Lesson T8L1. Pushing Branches to a Remote. Fork and clone it if you haven't done that already.

1 Verify that the remote-tracking branch origin/track-sales exists in the local repo, but there is no local copy of it.

You can use the git branch -a command to list all local and tracking branches.

git branch -a
* hiring
  master
  remotes/origin/HEAD -> origin/master
  remotes/origin/hiring
  remotes/origin/master
  remotes/origin/track-sales

The * in the output above indicates the currently active branch.

Note how there is no track-sales in the list of branches (i.e., no local branch named track-sales), but there is a remotes/origin/track-sales (i.e., the remote-tracking branch)


Observe how the branch track-sales appear under REMOTESorigin but not under BRANCHES.


2 Create a local copy of the remote branch origin/track-sales.

You can use the git switch -c <branch> <remote-branch> command for this e.g.,

git switch -c track-sales origin/track-sales

Locate the track-sales remote-tracking branch (look under REMOTESorigin), right-click, and choose Checkout....

In the next dialog, choose as follows:


The above command/action does several things:

  1. Creates a new branch track-sales.
  2. Sets the new branch to track the remote branch origin/track-sales, which means the local branch ref track-sales will also move to where the origin/track-sales is.
  3. Switch to the newly-created branch i.e., makes it the current branch.

3 Add a commit to the track-sales branch and push to the remote, to verify that the local branch is tracking the remote branch.

Commands to perform this step in one shot:

echo "5 reams of paper" >> sales.txt
git commit -am "Update sales.txt"
git push origin track-sales

done!

Use case 2: Working with branches that were added to the remote repository after you cloned it e.g., a branch someone else pushed to the remote after you cloned.

Simply fetch to update your local repository with information about the new branch. After that, you can create a local copy of it and work with it just as you did in Use Case 1.


T8L3. Deleting Branches from a Remote


Often, you'll need to delete a branch in a remote repo after it has served its purpose.

This lesson covers that part.

To delete a branch in a remote repository, you simply tell Git to remove the reference to that branch from the remote. This does not delete the branch from your local repository — it only removes it from the remote, so others won’t see it anymore. This is useful for cleaning up clutter in the remote repo e.g., delete old or merged branches that are no longer needed on the remote.

HANDS-ON: Delete (and restore) branches in a remote

Preparation Fork the samplerepo-books to your GitHub account. When doing so, un-tick the Copy the master branch only option.
After forking, go to the fork and ensure all three branches are in there.

Clone the fork to your computer.

1 Create a local copy of the fantasy branch in your clone.

Follow instructions in Lesson T8L2. Pulling Branches from a Remote.

2 Delete the remote branch fantasy.

You can use the git push <remote> --delete <branch> command to delete a branch in a remote. This is like pushing changes in a branch to a remote, except we request the branch to be deleted instead, by adding the --delete switch.

git push origin --delete fantasy

Locate the remote branch under REMOTESorigin, right-click on the branch name, and choose Delete...:


3 Verify that the branch was deleted from the remote, by going to the fork on GitHub and checking the branches page https://github.com/{YOUR_USERNAME}/samplerepo-books/branches
e.g., https://github.com/johndoe/samplerepo-books/branches.

Also verify that the local copy has not been deleted.

4 Restore the remote branch from the local copy.

Push the local branch to the remote, while enabling the tracking option (as if pushing the branch to the remote for the first time), as covered in Lesson T8L1. Pushing Branches to a Remote.

In the above steps, we first created a local copy of the branch before deleting it in the remote repo. Doing so is optional. You can delete a remote branch without ever checking it out locally — you just need to know its name on the remote. Deleting the remote branch directly without creating a local copy is recommended if you simply want to clean up a remote branch you no longer need.

done!


T8L4. Renaming Branches in a Remote


Occasionally, you might need to rename a branch in a remote repo.

This lesson covers that part.

Git does not have a way to rename remote branches in place. Instead, you create a new branch with the desired name and delete the old one. This involves renaming your local branch to the new name, pushing it to the remote (which effectively creates a new remote branch), and then removing the old branch from the remote. This ensures the remote reflects the updated name while preserving the commit history and any work already done on the branch.

While Git cannot rename a remote branch in place, GitHub allows you to rename a branch in a remote repo. If you use this approach, the local repo still needs to be updated to reflect the change.

HANDS-ON: Rename branches in a remote

Preparation You can use the fork and the clone of the samplerepo-books that you created in Lesson T8L3. Deleting Branches from a Remote.

Target Rename the branch fantasy in the remote (i.e., your fork) to fantasy-books.

Steps

  1. Ensure you are in the master branch.
  2. Create a local copy of the remote-tracking branch origin/fantasy.
  3. Rename the local copy of the branch to fantasy-books.
  4. Push the renamed local branch to the remote, while setting up tracking for the branch as well.
  5. Delete the remote branch.
git switch master                     # ensure you are on the master branch
git switch -c fantasy origin/fantasy  # create a local copy, tracking the remote branch
git branch -m fantasy fantasy-books   # rename local branch
git push -u origin fantasy-books      # push the new branch to remote, and set it to track
git push origin --delete fantasy      # delete the old branch

You can run the git log --oneline --decorate --graph --all to check the revision graph after each step. The final outcome should be something like the below:

* 355915c (HEAD -> fantasy-books, origin/fantasy-books) Add fantasy.txt
| * 027b2b0 (origin/master, origin/HEAD, master) Merge branch textbooks
|/|
| * a6ebaec (origin/textbooks) Add textbooks.txt
|/
* d462638 Add horror.txt

Perform the above steps (each step was covered in a previous lesson).


done!


At this point: You should now be able to work with branches in a remote repo, and keep them synchronised with branches in the local repo.

What's next: More trails to be added in the future.



Guidance for the item(s) below:

Let's learn how to create a pull request (PRs) on GitHub; you need to create one for your project this week.

[W3.2] RCS: Creating Pull Requests

W3.2a

Tools → Git and GitHub → Creating PRs

A pull request (PR for short) is a mechanism for contributing code to a remote repo i.e., "I'm requesting you to pull my proposed changes to your repo". It's feature provided by RCS platforms such as GitHub. For this to work, the two repos must have a shared history. The most common case is sending PRs from a fork to its repo.

Suppose you want to propose some changes to a GitHub repo (e.g., samplerepo-pr-practice) as a pull request (PR).

samplerepo-pr-practice is an unmonitored repo you can use to practice working with PRs. Feel free to send PRs to it.

Given below is a scenario you can try in order to learn how to create PRs:

1. Fork the repo onto your GitHub account.

2. Clone it onto your computer.

3. Commit your changes e.g., add a new file with some contents and commit it.

  • Option A - Commit changes to the master branch
  • Option B - Commit to a new branch e.g., create a branch named add-intro (remember to switch to the master branch before creating a new branch) and add your commit to it.

4. Push the branch you updated (i.e., master branch or the new branch) to your fork, as explained here.

5. Initiate the PR creation:

  1. Go to your fork.

  2. Click on the Pull requests tab followed by the New pull request button. This will bring you to the Compare changes page.

  3. Set the appropriate target repo and the branch that should receive your PR, using the base repository and base dropdowns. e.g.,
    base repository: se-edu/samplerepo-pr-practice base: master

    Normally, the default value shown in the dropdown is what you want but in case your fork has , the default may not be what you want.

  4. Indicate which repo:branch contains your proposed code, using the head repository and compare dropdowns. e.g.,
    head repository: myrepo/samplerepo-pr-practice compare: master

6. Verify the proposed code: Verify that the diff view in the page shows the exact change you intend to propose. If it doesn't, as necessary.

7. Submit the PR:

  1. Click the Create pull request button.

  2. Fill in the PR name and description e.g.,
    Name: Add an introduction to the README.md
    Description:

    Add some paragraph to the README.md to explain ...
    Also add a heading ...
    
  3. If you want to indicate that the PR you are about to create is 'still work in progress, not yet ready', click on the dropdown arrow in the Create pull request button and choose Create draft pull request option.

  4. Click the Create pull request button to create the PR.

  5. Go to the receiving repo to verify that your PR appears there in the Pull requests tab.

The next step of the PR lifecycle is the PR review. The members of the repo that received your PR can now review your proposed changes.

  • If they like the changes, they can merge the changes to their repo, which also closes the PR automatically.
  • If they don't like it at all, they can simply close the PR too i.e., they reject your proposed change.
  • In most cases, they will add comments to the PR to suggest further changes. When that happens, GitHub will notify you.

You can update the PR along the way too. Suppose PR reviewers suggested a certain improvement to your proposed code. To update your PR as per the suggestion, you can simply modify the code in your local repo, commit the updated code to the same branch as before, and push to your fork as you did earlier. The PR will auto-update accordingly.

Sending PRs using the master branch is less common than sending PRs using separate branches. For example, suppose you wanted to propose two bug fixes that are not related to each other. In that case, it is more appropriate to send two separate PRs so that each fix can be reviewed, refined, and merged independently. But if you send PRs using the master branch only, both fixes (and any other change you do in the master branch) will appear in the PRs you create from it.

To create another PR while the current PR is still under review, create a new branch (remember to switch back to the master branch first), add your new proposed change in that branch, and create a new PR following the steps given above.

It is possible to create PRs within the same repo e.g., you can create a PR from branch feature-x to the master branch, within the same repo. Doing so will allow the code to be reviewed by other developers (using PR review mechanism) before it is merged.

Problem: merge conflicts in ongoing PRs, indicated by the message This branch has conflicts that must be resolved. That means the upstream repo's master branch has been updated in a way that the PR code conflicts with that master branch. Here is the standard way to fix this problem:

  1. Pull the master branch from the upstream repo to your local repo.
    git checkout master
    git pull upstream master
    
  2. In the local repo, attempt to merge the master branch (that you updated in the previous step) onto the PR branch, in order to bring over the new code in the master branch to your PR branch.
    git checkout pr-branch  # assuming pr-branch is the name of branch in the PR
    git merge master
    
  3. The merge you are attempting will run into a merge conflict, due to the aforementioned conflicting code in the master branch. Resolve the conflict manually (this topic is covered elsewhere), and complete the merge.
  4. Push the PR branch to your fork. As the updated code in that branch no longer is conflicting with the master branch, the merge conflict alert in the PR will go away automatically.


Guidance for the item(s) below:

As your project gets bigger and changes become more frequent, it's natural to look for ways to automate the many steps involved in going from the code you write in the editor to an executable product. This is a good time to start learning about that aspect too.

[W3.3] Automating the Build Process

W3.3a

Implementation → Integration → Introduction → What

Combining parts of a software product to form a whole is called integration. It is also one of the most troublesome tasks and it rarely goes smoothly.


W3.3b

Implementation → Integration → Build Automation → What

Build automation tools automate the steps of the build process, usually by means of build scripts.

In a non-trivial project, building a product from its source code can be a complex multistep process. For example, it can include steps such as: pull code from the revision control system, compile, link, run automated tests, automatically update release documents (e.g., build number), package into a distributable, push to repo, deploy to a server, delete temporary files created during building/testing, email developers of the new build, and so on. Furthermore, this build process can be done ‘on demand’, it can be scheduled (e.g., every day at midnight) or it can be triggered by various events (e.g., triggered by a code push to the revision control system).

Some of these build steps such as compiling, linking and packaging, are already automated in most modern IDEs. For example, several steps happen automatically when the ‘build’ button of the IDE is clicked. Some IDEs even allow customization of this build process to some extent.

However, most big projects use specialized build tools to automate complex build processes.

Some popular build tools relevant to Java developers: Gradle, Maven, Apache Ant, GNU Make

Some other build tools: Grunt (JavaScript), Rake (Ruby)

Some build tools also serve as dependency management tools. Modern software projects often depend on third party libraries that evolve constantly. That means developers need to download the correct version of the required libraries and update them regularly. Therefore, dependency management is an important part of build automation. Dependency management tools can automate that aspect of a project.

Maven and Gradle, in addition to managing the build process, can play the role of dependency management tools too.


W3.3c

Implementation → Integration → Build Automation → Continuous integration and continuous deployment

An extreme application of build automation is called continuous integration (CI) in which integration, building, and testing happens automatically after each code change.

A natural extension of CI is Continuous Deployment (CD) where the changes are not only integrated continuously, but also deployed to end-users at the same time.

Some examples of CI/CD tools: Travis, Jenkins, Appveyor, CircleCI, GitHub Actions



Guidance for the item(s) below:

Next, we have a few more Java topics that you need as you move from a 'programming exercise' mode to a 'production code' mode.

[W3.4] Java: JavaDoc, file I/O, packages, JARs

W3.4a

Implementation → Documentation → Tools → JavaDoc → What

Video

JavaDoc is a tool for generating API documentation in HTML format from comments in the source code. In addition, modern IDEs use JavaDoc comments to generate explanatory tooltips.

An example method header comment in JavaDoc format:

/**
 * Returns an Image object that can then be painted on the screen.
 * The url argument must specify an absolute {@link URL}. The name
 * argument is a specifier that is relative to the url argument.
 * <p>
 * This method always returns immediately, whether or not the
 * image exists. When this applet attempts to draw the image on
 * the screen, the data will be loaded. The graphics primitives
 * that draw the image will incrementally paint on the screen.
 *
 * @param url An absolute URL giving the base location of the image.
 * @param name The location of the image, relative to the url argument.
 * @return The Image at the specified URL.
 * @see Image
 */
public Image getImage(URL url, String name) {
    try {
        return getImage(new URL(url, name));
    } catch (MalformedURLException e) {
        return null;
    }
}

Generated HTML documentation:

Tooltip generated by IntelliJ IDE:


W3.4b

Implementation → Documentation → Tools → JavaDoc → How

In the absence of more extensive guidelines (e.g., given in a coding standard adopted by your project), you can follow the two examples below in your code.

A minimal JavaDoc comment example for methods:

/**
 * Returns lateral location of the specified position.
 * If the position is unset, NaN is returned.
 *
 * @param x X coordinate of position.
 * @param y Y coordinate of position.
 * @param zone Zone of position.
 * @return Lateral location.
 * @throws IllegalArgumentException If zone is <= 0.
 */
public double computeLocation(double x, double y, int zone)
    throws IllegalArgumentException {
    // ...
}

A minimal JavaDoc comment example for classes:

package ...

import ...

/**
 * Represents a location in a 2D space. A <code>Point</code> object corresponds to
 * a coordinate represented by two integers e.g., <code>3,6</code>
 */
public class Point {
    // ...
}

Resources:

W3.4c :

C++ to Java → Miscellaneous Topics → File access

You can use the java.io.File class to represent a file object. It can be used to access properties of the file object.

This code creates a File object to represent a file fruits.txt that exists in the data directory relative to the current working directory and uses that object to print some properties of the file.

import java.io.File;

public class FileClassDemo {

    public static void main(String[] args) {
        File f = new File("data/fruits.txt");
        System.out.println("full path: " + f.getAbsolutePath());
        System.out.println("file exists?: " + f.exists());
        System.out.println("is Directory?: " + f.isDirectory());
    }

}

full path: C:\sample-code\data\fruits.txt
file exists?: true
is Directory?: false

If you use backslash to specify the file path in a Windows computer, you need to use an additional backslash as an escape character because the backslash by itself has a special meaning. e.g., use "data\\fruits.txt", not "data\fruits.txt". Alternatively, you can use forward slash "data/fruits.txt" (even on Windows).

You can read from a file using a Scanner object that uses a File object as the source of data.

This code uses a Scanner object to read (and print) contents of a text file line-by-line:

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class FileReadingDemo {

    private static void printFileContents(String filePath) throws FileNotFoundException {
        File f = new File(filePath); // create a File for the given file path
        Scanner s = new Scanner(f); // create a Scanner using the File as the source
        while (s.hasNext()) {
            System.out.println(s.nextLine());
        }
    }

    public static void main(String[] args) {
        try {
            printFileContents("data/fruits.txt");
        } catch (FileNotFoundException e) {
            System.out.println("File not found");
        }
    }

}

i.e., contents of the data/fruits.txt

5 Apples
3 Bananas
6 Cherries

You can use a java.io.FileWriter object to write to a file.

The writeToFile method below uses a FileWriter object to write to a file. The method is being used to write two lines to the file temp/lines.txt.

import java.io.FileWriter;
import java.io.IOException;

public class FileWritingDemo {

    private static void writeToFile(String filePath, String textToAdd) throws IOException {
        FileWriter fw = new FileWriter(filePath);
        fw.write(textToAdd);
        fw.close();
    }

    public static void main(String[] args) {
        String file2 = "temp/lines.txt";
        try {
            writeToFile(file2, "first line" + System.lineSeparator() + "second line");
        } catch (IOException e) {
            System.out.println("Something went wrong: " + e.getMessage());
        }
    }

}

Contents of the temp/lines.txt:

first line
second line

Note that you need to call the close() method of the FileWriter object for the writing operation to be completed.

You can create a FileWriter object that appends to the file (instead of overwriting the current content) by specifying an additional boolean parameter to the constructor.

The method below appends to the file rather than overwrites.

private static void appendToFile(String filePath, String textToAppend) throws IOException {
    FileWriter fw = new FileWriter(filePath, true); // create a FileWriter in append mode
    fw.write(textToAppend);
    fw.close();
}

The java.nio.file.Files is a utility class that provides several useful file operations. It relies on the java.nio.file.Paths file to generate Path objects that represent file paths.

This example uses the Files class to copy a file and delete a file.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class FilesClassDemo {

    public static void main(String[] args) throws IOException{
        Files.copy(Paths.get("data/fruits.txt"), Paths.get("temp/fruits2.txt"));
        Files.delete(Paths.get("temp/fruits2.txt"));
    }

}

The techniques above are good enough to manipulate simple text files. Note that it is also possible to perform file I/O operations using other classes.


W3.4d :

C++ to Java → Miscellaneous Topics → Packages

You can organize your types (i.e., classes, interfaces, enumerations, etc.) into packages for easier management (among other benefits).

To create a package, you put a package statement at the very top of every source file in that package. The package statement must be the first line in the source file and there can be no more than one package statement in each source file. Furthermore, the package of a type should match the folder path of the source file. Similarly, the compiler will put the .class files in a folder structure that matches the package names.

The Formatter class below (in <source folder>/seedu/tojava/util/Formatter.java file) is in the package seedu.tojava.util. When it is compiled, the Formatter.class file will be in the location <compiler output folder>/seedu/tojava/util:

package seedu.tojava.util;

public class Formatter {
    public static final String PREFIX = ">>";

    public static String format(String s){
        return PREFIX + s;
    }
}

Package names are written in all lower case (not camelCase), using the dot as a separator. Packages in the Java language itself begin with java. or javax. Companies use their reversed Internet domain name to begin their package names.

For example, com.foobar.doohickey.util can be the name of a package created by a company with a domain name foobar.com

To use a public from outside its package, you must do one of the following:

  1. Use the to refer to the member
  2. Import the package or the specific package member

The Main class below has two import statements:

  • import seedu.tojava.util.StringParser: imports the class StringParser in the seedu.tojava.util package
  • import seedu.tojava.frontend.*: imports all the classes in the seedu.tojava.frontend package
package seedu.tojava;

import seedu.tojava.util.StringParser;
import seedu.tojava.frontend.*;

public class Main {

    public static void main(String[] args) {

        // Using the fully qualified name to access the Processor class
        String status = seedu.tojava.logic.Processor.getStatus();

        // Using the StringParser previously imported
        StringParser sp = new StringParser();

        // Using classes from the tojava.frontend package
        Ui ui = new Ui();
        Message m = new Message();

    }
}

Note how the class can still use the Processor without importing it first, by using its fully qualified name seedu.tojava.logic.Processor

Importing a package does not import its sub-packages, as packages do not behave as hierarchies despite appearances.

import seedu.tojava.frontend.* does not import the classes in the sub-package seedu.tojava.frontend.widget.

If you do not use a package statement, your type doesn't have a package -- a practice not recommended (except for small code examples) as it is not possible for a type in a package to import a type that is not in a package.

Optionally, a static import can be used to import static members of a type so that the imported members can be used without specifying the type name.

The class below uses static imports to import the constant PREFIX and the method format() from the seedu.tojava.util.Formatter class.

import static seedu.tojava.util.Formatter.PREFIX;
import static seedu.tojava.util.Formatter.format;

public class Main {

    public static void main(String[] args) {

        String formatted = format("Hello");
        boolean isFormatted = formatted.startsWith(PREFIX);
        System.out.println(formatted);
    }
}

Formatter class


Note how the class can use PREFIX and format() (instead of Formatter.PREFIX and Formatter.format()).

When using the command line to compile/run Java, you should take the package into account.

If the seedu.tojava.Main class is defined in the file Main.java,

  • when compiling from the <source folder>, the command is:
    javac seedu/tojava/Main.java
  • when running it from the <compiler output folder>, the command is:
    java seedu.tojava.Main

Resources:

W3.4e :

C++ to Java → Miscellaneous Topics → Using JAR files

Java applications are typically delivered as JAR (short for Java Archive) files. A JAR contains Java classes and other resources (icons, media files, etc.).

An executable JAR file can be launched using the java -jar command e.g., java -jar foo.jar launches the foo.jar file.

The IDE or build tools such as Gradle can help you to package your application as a JAR file.

See the tutorial Working with JAR files @se-edu/guides to learn how to create and use JAR files.



Guidance for the item(s) below:

As you know, one of the objectives of the iP is to raise the quality of your code. We'll be learning about various ways to improve the code quality in the next few weeks, starting with coding standards.

[W3.5] Code Quality: Coding Standards

Guidance for the item(s) below:

In-video quizzes

The Q+ icon indicates that the video has an in-video quiz. Submitting the in-video quiz can earn you bonus participation marks.

In-video quizzes are in only a small number of pre-recorded videos that are more important than the rest. They are a very light way to engage you with the video a bit more than just passively watching.

Please watch the video given below as it has some extra points not given in the text version.

W3.5a

Implementation → Code Quality → Introduction → What

Video Q+

Always code as if the person who ends up maintaining your code will be a violent psychopath who knows where you live. -- Martin Golding

Production code needs to be of high quality. Given how the world is becoming increasingly dependent on software, poor quality code is something no one can afford to tolerate.


W3.5b

Implementation → Code Quality → Style → Introduction

Video Q+

One essential way to improve code quality is to follow a consistent style. That is why software engineers usually follow a strict coding standard (aka style guide).

The aim of a coding standard is to make the entire codebase look like it was written by one person. A coding standard is usually specific to a programming language and specifies guidelines such as the locations of opening and closing braces, indentation styles and naming styles (e.g., whether to use Hungarian style, Pascal casing, Camel casing, etc.). It is important that the whole team/company uses the same coding standard and that the standard is generally not inconsistent with typical industry practices. If a company's coding standard is very different from what is typically used in the industry, new recruits will take longer to get used to the company's coding style.

IDEs can help to enforce some parts of a coding standard e.g., indentation rules.


Exercises:

What is the recommended approach regarding coding standards?




Guidance for the item(s) below:

As promised last week, let's learn some more sophisticated ways of testing.

[W3.6] Developer Testing

W3.6a

Quality Assurance → Testing → Developer Testing → What

Developer testing is the testing done by the developers themselves as opposed to dedicated testers or end-users.


W3.6b

Quality Assurance → Testing → Developer Testing → Why

Video Q+

Delaying testing until the full product is complete has a number of disadvantages:

  • Locating the cause of a test case failure is difficult due to the larger search space; in a large system, the search space could be millions of lines of code, written by hundreds of developers! The failure may also be due to multiple inter-related bugs.
  • Fixing a bug found during such testing could result in major rework, especially if the bug originated from the design or during requirements specification i.e., a faulty design or faulty requirements.
  • One bug might 'hide' other bugs, which could emerge only after the first bug is fixed.
  • The delivery may have to be delayed if too many bugs are found during testing.

Therefore, it is better to do early testing, as hinted by the popular rule of thumb given below, also illustrated by the graph below it.

The earlier a bug is found, the easier and cheaper to have it fixed.

Such early testing software is usually, and often by necessity, done by the developers themselves i.e., developer testing.


Exercises:

Implications of developers testing their own code


Cost of bug fixing over time




[W3.7] Unit Testing

Video Q+

W3.7a

Quality Assurance → Testing → Test Automation → Test automation using test drivers

A test driver is the code that ‘drives’ the for the purpose of testing i.e., invoking the SUT with test inputs and verifying if the behavior is as expected.

PayrollTest ‘drives’ the Payroll class by sending it test inputs and verifies if the output is as expected.

public class PayrollTest {
    public static void main(String[] args) throws Exception {

        // test setup
        Payroll p = new Payroll();

        // test case 1
        p.setEmployees(new String[]{"E001", "E002"});
        // automatically verify the response
        if (p.totalSalary() != 6400) {
            throw new Error("case 1 failed ");
        }

        // test case 2
        p.setEmployees(new String[]{"E001"});
        if (p.totalSalary() != 2300) {
            throw new Error("case 2 failed ");
        }

        // more tests...

        System.out.println("All tests passed");
    }
}

W3.7b

Quality Assurance → Testing → Test Automation → Test automation tools

JUnit is a tool for automated testing of Java programs. Similar tools are available for other languages and for automating different types of testing.

This is an automated test for a Payroll class, written using JUnit libraries.

    // other test methods

    @Test
    public void testTotalSalary() {
        Payroll p = new Payroll();

        // test case 1
        p.setEmployees(new String[]{"E001", "E002"});
        assertEquals(6400, p.totalSalary());

        // test case 2
        p.setEmployees(new String[]{"E001"});
        assertEquals(2300, p.totalSalary());

        // more tests...
    }

Most modern IDEs have integrated support for testing tools. The figure below shows the JUnit output when running some JUnit tests using the Eclipse IDE.


W3.7c

Quality Assurance → Testing → Unit Testing → What

Unit testing: testing individual units (methods, classes, subsystems, ...) to ensure each piece works correctly.

In OOP code, it is common to write one or more unit tests for each public method of a class.

Here are the code skeletons for a Foo class containing two methods and a FooTest class that contains unit tests for those two methods.

class Foo {
    String read() {
        // ...
    }
    
    void write(String input) {
        // ...
    }
    
}
class FooTest {
    
    @Test
    void read() {
        // a unit test for Foo#read() method
    }
    
    @Test
    void write_emptyInput_exceptionThrown() {
        // a unit tests for Foo#write(String) method
    }  
    
    @Test
    void write_normalInput_writtenCorrectly() {
        // another unit tests for Foo#write(String) method
    }
}
import unittest

class Foo:
  def read(self):
      # ...
  
  def write(self, input):
      # ...


class FooTest(unittest.TestCase):
  
  def test_read(self):
      # a unit test for read() method
  
  def test_write_emptyIntput_ignored(self):
      # a unit test for write(string) method
  
  def test_write_normalInput_writtenCorrectly(self):
      # another unit test for write(string) method

Resources:

W3.7d

C++ to Java → JUnit → JUnit: Basic

When writing JUnit tests for a class Foo, the common practice is to create a FooTest class, which will contain various test methods for testing methods of the Foo class.

Suppose we want to write tests for the IntPair class below.

public class IntPair {
    int first;
    int second;

    public IntPair(int first, int second) {
        this.first = first;
        this.second = second;
    }

    /**
     * Returns The result of applying integer division first/second.
     * @throws Exception if second is 0.
     */
    public int intDivision() throws Exception {
        if (second == 0){
            throw new Exception("Divisor is zero");
        }
        return first/second;
    }

    @Override
    public String toString() {
        return first + "," + second;
    }
}

Here's a IntPairTest class to match (using JUnit 5).

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;

public class IntPairTest {

    @Test
    public void intDivision_nonZeroDivisor_success() throws Exception {
        // normal division results in an integer answer 2
        assertEquals(2, new IntPair(4, 2).intDivision());

        // normal division results in a decimal answer 1.9
        assertEquals(1, new IntPair(19, 10).intDivision());

        // dividend is zero but divisor is not
        assertEquals(0, new IntPair(0, 5).intDivision());
    }

    @Test
    public void intDivision_zeroDivisor_exceptionThrown() {
        try {
            assertEquals(0, new IntPair(1, 0).intDivision());
            fail(); // the test should not reach this line
        } catch (Exception e) {
            assertEquals("Divisor is zero", e.getMessage());
        }
    }

    @Test
    public void testStringConversion() {
        assertEquals("4,7", new IntPair(4, 7).toString());
    }
}
  • Note how each test method is marked with a @Test annotation.
  • Tests use assertEquals(expected, actual) methods (provided by JUnit) to compare the expected output with the actual output. If they do not match, the test will fail.
    JUnit comes with other similar methods such as assertNull, assertNotNull, assertTrue, assertFalse etc. [more ...]
  • Java code normally use camelCase for method names e.g., testStringConversion but when writing test methods, sometimes another convention is used:
    unitBeingTested_descriptionOfTestInputs_expectedOutcome
    e.g., intDivision_zeroDivisor_exceptionThrown
  • There are several ways to verify the code throws the correct exception. The second test method in the example above shows one of the simpler methods. If the exception is thrown, it will be caught and further verified inside the catch block. But if it is not thrown as expected, the test will reach fail() line and will fail as a result.

What to test for when writing tests? While test case design techniques is a separate topic altogether, it should be noted that the goal of these tests is to catch bugs in the code. Therefore, test using inputs that can trigger a potentially buggy path in the code. Another way to approach this is, to write tests such that if a future developer modified the method to unintentionally introduce a bug into it, at least one of the test should fail (thus alerting that developer to the mistake immediately).

In the example above, the IntPairTest class tests the IntPair#intDivision(int, int) method using several inputs, some even seemingly attempting to 'trick' the method into producing a wrong result. If the method still produces the correct output for such 'tricky' inputs (as well as 'normal' outputs), we can have a higher confidence on the method being correctly implemented.
However, also note that the current test cases do not (but probably should) test for the inputs (0, 0), to confirm that it throws the expected exception.


Resources:

W3.7e

Quality Assurance → Testing → Unit Testing → Stubs

A proper unit test requires the unit to be tested in isolation so that bugs in the cannot influence the test i.e., bugs outside of the unit should not affect the unit tests.

If a Logic class depends on a Storage class, unit testing the Logic class requires isolating the Logic class from the Storage class.

Stubs can isolate the from its dependencies.

Stub: A stub has the same interface as the component it replaces, but its implementation is so simple that it is unlikely to have any bugs. It mimics the responses of the component, but only for a limited set of predetermined inputs. That is, it does not know how to respond to any other inputs. Typically, these mimicked responses are hard-coded in the stub rather than computed or retrieved from elsewhere, e.g., from a database.

Consider the code below:

class Logic {
    Storage s;

    Logic(Storage s) {
        this.s = s;
    }

    String getName(int index) {
        return "Name: " + s.getName(index);
    }
}
interface Storage {
    String getName(int index);
}
class DatabaseStorage implements Storage {

    @Override
    public String getName(int index) {
        return readValueFromDatabase(index);
    }

    private String readValueFromDatabase(int index) {
        // retrieve name from the database
    }
}

Normally, you would use the Logic class as follows (note how the Logic object depends on a DatabaseStorage object to perform the getName() operation):

Logic logic = new Logic(new DatabaseStorage());
String name = logic.getName(23);

You can test it like this:

@Test
void getName() {
    Logic logic = new Logic(new DatabaseStorage());
    assertEquals("Name: John", logic.getName(5));
}

However, this logic object being tested is making use of a DataBaseStorage object which means a bug in the DatabaseStorage class can affect the test. Therefore, this test is not testing Logic in isolation from its dependencies and hence it is not a pure unit test.

Here is a stub class you can use in place of DatabaseStorage:

class StorageStub implements Storage {

    @Override
    public String getName(int index) {
        if (index == 5) {
            return "Adam";
        } else {
            throw new UnsupportedOperationException();
        }
    }
}

Note how the StorageStub has the same interface as DatabaseStorage, but is so simple that it is unlikely to contain bugs, and is pre-configured to respond with a hard-coded response, presumably, the correct response DatabaseStorage is expected to return for the given test input.

Here is how you can use the stub to write a unit test. This test is not affected by any bugs in the DatabaseStorage class and hence is a pure unit test.

@Test
void getName() {
    Logic logic = new Logic(new StorageStub());
    assertEquals("Name: Adam", logic.getName(5));
}

In addition to Stubs, there are other type of replacements you can use during testing, e.g., Mocks, Fakes, Dummies, Spies.


Resources:
  • Mocks Aren't Stubs by Martin Fowler -- An in-depth article about how Stubs differ from other types of test helpers.

Exercises:

Purpose of stubs



Guidance for the item(s) below:

While the JUnit concepts mentioned in the topic below are not strictly needed for the course projects, it is good to be aware of them so that you try some of them when applicable.

W3.7f : OPTIONAL

C++ to Java → JUnit → JUnit: Intermediate