Version control with Git

Home » Version control with Git
by Charlie Harrison
Contents:


Version control is a big part of professional software development, but even if you’ve never written a line of code it’s easy to make a compelling case for using it. Have you ever found yourself working on a document named “Final report v3 final – FINAL v2 last one this time.docx”? Or accidentally edited the wrong version of something and had to do it over again? Or regretted deleting something? Or had to combine comments and changes from several people working on their own versions of the same file? If you answered “yes” to any of these, then version control software could be for you.

A version control system (VCS) is a piece of software that lets you work on complex sets of files, keep track of changes and versions, and share and collaborate effectively. There are several popular VCS options, but the most popular by far is Git. This article gives a gentle introduction to Git, the key concepts and the essential commands you’ll need to start to find it useful. You’ll need to know how to run commands in a terminal or command prompt, but other than that, there is no assumed knowledge.

Let’s use a simple example of a project. We’re drawing some fruit. We’ll save our drawings in a directory called fruit-bowl, inside our home directory: ~/fruit-bowl.

Starting a repository and staging files with git init and git add

First off, you’ll need to install Git. Follow the official instructions here.

A project in Git is called a “repository”, or “repo” for short. The first step is to initialise our repo. We navigate into the project directory, and then run

$ git init .

This sets up our current working directory (indicated by ‘.’) as a git repo. Git creates a hidden directory to contain its working files, but we don’t have to worry about that – Git will manage it behind the scenes. Let’s get drawing!

We’ll start with an apple.

Great things come from humble beginnings. Once we’ve saved this file, we can ask Git what’s going on with git status:

$ git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    apple.png

nothing added to commit but untracked files present (use "git add" to track)

Git can see apple.png is in the project directory, but it’s not tracking it yet. We can add apple.png to Git’s list of tracked files like this:

$ git add apple.png

Next we’ll draw a banana.

It’s clearly a banana. Once we’ve saved the file, we can add banana.png to our repo and check the status again:

$ git add banana.png
$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   apple.png
    new file:   banana.png

The message says that our two files, apple.png and banana.png, are both “staged”.

Staging is a temporary snapshot. The files we’ve added are ready to save in a more permanent checkpoint, in the state they were in when we added them; we could carry on modifying and saving the files and it wouldn’t affect the staged versions; but if git add them again, the staged version will be overwritten.

Saving checkpoints with git commit and git tag

Is two pieces of fruit enough for a bowl? I’d say so, just about. Now that we have a minimum viable fruit-bowl, we’ll want to mark this moment by saving it as a checkpoint. This requires a new command: git commit. We’ll add a message with the -m flag:

$ git commit -m "First commit: basic (but awesome) coloured shapes"

Note: Git insists on a message for each commit (it’s not being controlling, it just knows what’s best for you). If you don’t add a message with the -m flag, Git will automatically launch a text editor editing a new commit file. The text editor might be vi or vim, nano, or something else, depending on your computer setup. Git will create the commit once you’ve written something and saved the file.

This moves the files from staging into a more permanent state. A commit is a significant milestone. The files in the commit will never change – whatever happens now, we can always restore things to the way they were when they were committed. Any changes we make will be saved in new commits, building on this point. It is technically possible to delete a commit from the history and expunge all record of it, or even delete the git repo altogether, but it’s pretty difficult to do it by accident. That’s comforting.

Git gives every commit a unique ID. In my case, the ID for this first commit is f31b695cc64422cb9113ccedaf44b7b307c8e1ab, which is not very memorable. Git often uses just the first 7 characters of the ID as a shorthand (e.g. f31b69), which is still not ideal. We can make it easier to retrieve this particular state later by labelling it with a tag. Let’s call it “v1.0”:

$ git tag v1.0

Now our repo looks like this:

The staging area is empty, and we have our first commit. The files in our local directory match the commit, so everything is up to date. We can carry on editing the files, safe in the knowledge that we can easily get back to this point whenever we want (more on this later, but the command we would use is git checkout v1.0).

Let’s add some finesse to our pictures.

Beautiful. Check the status again:

$ git status
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   apple.png
    modified:   banana.png

no changes added to commit (use "git add" and/or "git commit -a")

Git can see that we modified the local files since our last commit, and the staging area is currently empty. The message doesn’t say it, but v1.0 commit remains unchanged:

Let’s save this as another checkpoint. First we add the updated files to staging:

$ git add apple.png banana.png
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   apple.png
    modified:   banana.png

Now our repo looks like this:

We can commit the changes with a helpful commit message, and add a tag:

$ git commit -m "Added outlines"
$ git tag v1.1
$ git status
On branch main
nothing to commit, working tree clean

The repository is growing. Our new commit, v1.1, has a reference to the one before. The staging area is empty again (it was flushed when we made the commit), and the files in our working directory match the latest commit.

Adding features with git branch and git merge

Now, let’s suppose that we’ve looked at some actual pieces of fruit and decided to draw something even more impressive. I’m picturing more intricately shaped outlines, maybe some touches of detail, and eventually colour. We know that we can always go back to our previous commits, but it would be good to make a clear distinction between where we’re at now – the MVFB, minimum viable fruit bowl – and the new creative direction we’re taking. Git has the perfect solution: branches.

You might have noticed that when we called git status above, the message started with the line On branch main. We’re going to create a new branch to store our progress. Let’s name it “outlines”.

$ git branch outlines

Now the repository looks like this:

It’s a subtle change. We’ve created the outlines branch, but it doesn’t currently have any commits of its own. The last commit belongs to both the main and outlines branches.

To start working on the new branch we’ll need to perform a “checkout”.

$ git checkout outlines

A checkout tells Git “I want to work on this thing now”. You can pass in commit IDs, tags, or branch names as arguments.

Let’s make some edits to the files. We’ll delete the old shapes, and replace with some fancy outlines:

They look amazing, obviously. We can stage our changes using both the file names like before, or we can use the shortcut ‘*’ to stage all of our changes, and then check the status:

$ git add *
$ git status
On branch outlines
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   apple.png
    modified:   banana.png

Notice that we are now, to quote Git, “On branch outlines”.

Let’s commit those changes to the repo to make another checkpoint, and tag it as “v2.0”.

$ git commit -m "Fancy outlines with details"
$ git tag v2.0

Now we have this:

The outlines branch has its first unique commit, matching our working directory. The tips of the two branches have diverged!

Hang on though. When we stop and think about it, what’s a fruit bowl without an orange? We need to add an orange. Urgently. We should update our main branch, before we carry on with this more detailed work. This is where Git branches really come in handy. We can go back to editing the main branch with one line:

$ git checkout main

That replaces our local files with the latest commit in main (which is v1.1):

Time to add an orange.

Beautiful. Let’s add it to the repo. First we stage the new file:

$ git add orange.png
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   orange.png

The only change is orange, but when we commit this change, the commit contains all the files in their latest state.

$ git commit -m "Added awesome orange"
$ git tag v1.2

Phew! We have an orange on the main branch. Now let’s go back to the new, improved version we were working on before the orange emergency.

$ git checkout outlines

Notice that orange.png has disappeared from our project directory in ~/fruit-bowl. We’re back how we were when we made the last commit in outlines, the one we tagged v2.0. Let’s add some colour to our new apple and banana.

You know the drill by now. Stage the changes, commit, and tag.

$ git add *
$ git commit -m "Added colour to fancy outlines"
$ git tag v2.1

 The new fancy coloured-in apple and banana are committed to the repo, and I think it’s fair to say they’re an improvement on the originals. The orange only exists in the main branch though, and it’s already perfect. We want to combine the two branches, taking the best of both. And we can! With git merge.

Merging a branch takes the changes made in that branch and applies them to our current branch. We want to update the main branch with the edits we made in outlines, so first we have to switch back to main with checkout. Then, we can use merge to take the changes from outlines and add them in. This merge will automatically create a new commit, so we pass in a message, and then add a tag:

$ git checkout main
$ git merge outlines -m "Merged branch 'outlines': fancy outlines with colour"
$ git tag v3.0

Now this is the state of play:

The exact behaviour of merge varies depending on the comparative state of the two branches. If the changes in the two branches modify the same part of the same file, you get what’s known as a merge conflict. Git does its best to walk you through this potentially painful process, but you can read about it in the documentation here.

Sharing and backing up code with git push

The world deserves to see our astounding work, but even if we aren’t ready to share, it’s good to have a backup in case something happens to our local machine. With Git we can save a copy of our code to a remote server, along with all the commits. There are several excellent platforms that provide hosting for Git repos with generous free tiers for individual users, but by far the most famous and widely used is GitHub. (GitLab is probably second-placed and is popular with enterprise customers, but is less beginner-friendly). The source code for Git itself is managed on GitHub.

In this section we’re going to create a remote copy of our work, hosted on Github. You can find instructions on the GitHub website here, or you can follow along in this article and click through the links. there are three necessary preparation steps: first, you’ll need to create an account on GitHub. Then you’ll need to authenticate, so that Git on your local computer can speak to GitHub. Finally, create a new repository on GitHub. Since we already have a working repository, it’s best to leave the new one empty, i.e. don’t check the boxes to add a README, a .gitignore file, or a licence.

I set up a repository called fruit-bowl with the URL https://github.com/charliejharrison/fruit-bowl. Your repo will have a different address. We want to be able to push code from our local machine to this GitHub repo. Back in our local repo, we have to tell git to use the new GitHub address.

$ git remote add origin git@github.com:charliejharrison/fruit-bowl.git

The word “origin” here is the name for our remote repository. This could be whatever we want, and in very complex projects you might have more than one remote, but the convention is to use “origin” and that’s what you’ll find on most projects.

We want to copy our files and history to the remote repo. First we need to tell Git how to handle the branches we’ve created. Our local branches are called main and outlines, but our brand new repo has no branches yet – it’s completely empty – so we need to tell Git what to do with the branch main when we push it to the remote repo. This is called setting the “upstream”. Make sure you’re working on the main branch by running git status and, if necessary, git checkout main. Then we can use one command to create a new branch in the remote (which we named origin), set it as the upstream, and push our active branch to it:

$ git push -u origin main

The flag -u is short for --set-upstream. We only need to do this once per branch; from now on, whenever we’re on the main branch we can simply call git push.

This is what our overall situation looks like now:

There are some subtleties to point out here. Even though we pushed the main branch and not outlines, the commits in outlines also appear in origin. Because the commits on outlines are also ancestors of the latest commit on main, they get copied too. Those commits don’t have the branch label, so you couldn’t copy the remote repo and run git checkout outlines, but it would be possible to access all the commits in outlines by stepping back through the history from the branch tip (or HEAD) of main.

We might want to push outlines too, especially if we hadn’t yet merged it into main. To do that we could switch over to it with git checkout outlines, then git push -u origin outlines. Alternatively, we could have pushed all branches at once with one command:

git push --all origin

This will automatically create new branches on the remote and set the upstreams as appropriate.

One more thing: the tags we created only exist in our local repository. They don’t get pushed along with the code. To copy the tags too, we can use:

$ git push --tags origin

Now everything in our local repo should be safely stored in the remote. We can get Git to print a summary of the current commit and all its ancestors with the command git log. This prints commit messages, IDs, tags, and branch labels, and the time, date, and author of each commit, in reverse-chronological order (newest commit first). Here’s what mine looks like (though with the email address redacted):

$ git log
commit 4f8e197b8cb3af4393fc192f672d61ab5614a240 (HEAD -> main, tag: v3.0, origin/main)
Merge: 21f1334 da7de72
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 20:01:45 2025 +0100

    Merged branch 'outlines': fancy outlines with colour

commit da7de722bc11750e8127b3c664242c2479e37b77 (tag: v2.1, origin/outlines, outlines)
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 19:56:21 2025 +0100

    Added colour to fancy outlines

commit 21f13345680b2e24487a8da6199d3607d48fc9d7
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 19:28:46 2025 +0100

    Added awesome orange

commit 2e54e8d70463b9ea0553577baa45cb80834322b1 (tag: v2.0)
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 18:59:24 2025 +0100

    Fancy outlines with details

commit aca851b614f0c13fa822b06d7adc9df622e17f1d (tag: v1.1)
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 18:36:06 2025 +0100

    Added outlines

commit f31b695cc64422cb9113ccedaf44b7b307c8e1ab (tag: v1.0)
Author: charliejharrison <***@gmail.com>
Date:   Fri Sep 19 13:41:29 2025 +0100

Reusing code on Github by cloning and forking

Git and Github make it super easy to share code – yours, and other people’s! Github is the most popular choice for open source projects and scientific papers that publish their code, so knowing how to access and contribute to code from there opens up enormous possibilities.

If you’re interested in a project you find on Github, you have two options to use that code.

The first option is to make a local copy. This is called cloning. You copy the project’s URL, then use git to download the files and metadata:

$ git clone https://github.com/charliejharrison/fruit-bowl
Cloning into 'fruit-bowl'...
remote: Enumerating objects: 21, done.
remote: Counting objects: 100% (21/21), done.
remote: Compressing objects: 100% (18/18), done.
remote: Total 21 (delta 3), reused 21 (delta 3), pack-reused 0 (from 0)
Receiving objects: 100% (21/21), 35.82 KiB | 17.91 MiB/s, done.
Resolving deltas: 100% (3/3), done.

Now we have a complete local repo, with the remote one that we cloned from already linked as the upstream. We can track changes, create commits and branches, and download any updates with git pull. We could also try to send our changes to the remote with git push, but this will only work if we have permission.

The second option is to make a new remote copy. This is called forking. A fork is a copy of the source repo on your Github account. You can clone your forked repo to create a local copy, modify it, and push your changes back to your remote. The local copy you create with git clone will have your fork as the upstream, and the original source repo is upstream of your fork. This means that if you’re happy with what you’ve done, you can request that your changes are included in the original repo (the upstream) by submitting a pull request on Github. The owner of the upstream will review your pull requests (PR for short) and decide if and how to merge it in.

I look forward to your fruit submissions!

Summary and further reading

This diagram summaries most of what we’ve covered in this article.

You can get a high-level view of your state of play with git status, and history of the commits and branches with git log.

Hopefully this is enough to make you want to use Git, and to get you up and running, but there is much, much more to learn! For example, you can checkout just a single file at a time instead of a whole commit. You can unstage changes before they’re committed, and you can temporarily put unfinished changes to one side with git stash. If you, or more likely someone you work with, makes a mistake, you can fix errors with reset or restore, and you can figure out whose fault it was with git blame. The list goes on.

Luckily there are many useful and well-written resources to help you continue your Git journey.

The official Git book, written by Scott Chacon and Ben Straub and published by Apress, is one of the most comprehensive. Much of this article is inspired by or fact-checked against parts of the first three chapters. It’s available in full here.

Software Carpentry have produced a course that teaches Git and Github. The Software Carpentry curriculum covers other areas too, but you can find and sign up to upcoming workshops here, or access all the code and lesson materials for the Git lesson here.

Cheat sheets are a useful way to recap on the most useful commands once you understand the concepts. You can find one produced by the Git community here, or one from the GitHub Professional Services team here.

Finally, to learn more about GitHub, particularly for collaboration, try the official GitHub training manual here or the full documentation here.