Rewinding Time with Git

Introduction to Git

“It was working ten minutes ago”. I remember saying that numerous times as a student, and now many of my students say something similar. If only there were a way to rewind our code by ten minutes, or to see what’s changed about our code in the last ten minutes.

This blog post demonstrates how we can do both of these things with Git.

Git is an incredibly versatile tool, capable of doing far more than we’ll cover in this blog post. Git gives us the ability to create different branches of our code, synchronize our work across different computers, and work together on projects with multiple developers. But this blog post doesn’t try to cover all those uses of Git. This blog post covers a more focused and specific use of Git: to rewind time for our code — to look back into the past to see what’s changed, or to go back to an older version of our code.

Git on the Command Line

This blog post uses Git on the command line in UNIX (macOS, Linux, BSD, etc.), but all the terminology here generalizes to using Git inside a code editor like Visual Studio Code. If you’d like to follow this blog post on the command line, ensure that you can use Git in your shell by running:

% git version
git version 2.39.3 (Apple Git-146)

If the git command fails to execute, you’ll need to install Git on your system and make sure it’s included in your PATH environment variable.

Initializing a Repository

Each assignment or course project you work on should have its own Git repository. A repository, for our simplified use of Git, can be thought of as a collection of snapshots in time of your work. A repository could contain a snapshot of how your code looked last night, another snapshot of how it looked this morning, and another snapshot of how it looked ten minutes ago.

You can store your repository in any number of places, from your own computer, to a shared server used by yourself and other students, to GitHub. For this blog post, we’ll keep things as simple as possible: we’ll keep the Git repository for your assignment on your computer, in the same directory as the assignment code itself.

To begin, we have to initialize an empty repository. For this blog post, let’s assume your assignment will be located in an empty directory called ~/assignment1/. But, when you’re initializing repositories for your own assignments and projects, it doesn’t matter if the directory already exists and already has some of your code in it. Here, though, we’ll create the empty directory and initialize the repository as follows:

% mkdir ~/assignment1/
% cd ~/assignment1/
% git init
Initialized empty Git repository in /[...]/assignment1/.git/

Next, we’ll create an empty file named .gitignore. This single empty file will be the only content saved in our first snapshot (regardless of whether or not you already have code in the ~/assignment1/ directory). You’ll almost certainly never roll back to this snapshot, but making this simple first snapshot is the easiest way to get our chain of snapshots rolling. The git commands in this sequence might seem like magic at the moment, but we’ll explain both of them shortly:

% touch .gitignore
% git add .gitignore
% git commit -m "First commit"

A Sample Assignment

Our sample assignment will be a Java application with two classes: Cow and Barn. Let’s create two Java files, ~/assignment1/Cow.java:

public class Cow {}

and ~/assignment1/Barn.java:

public class Barn {}

Simple though they are, the code compiles:

% javac *.java

We should get into the habit of making a Git snapshot anytime we’ve made a change to the code that we might want to rewind back to. So, after we create these two .java files, despite them not yet having meaningful content, we’ll create another snapshot. You probably wouldn’t usually make a snapshot of such a minor change, but there’s little harm in creating more snapshots instead of too few in your own personal repository.

Staging Files

Staging (or adding) files is the process of choosing which files have changed that we want to add to the next snapshot. That is, when we generate a snapshot, it’ll consist of all the files in the previous snapshot, amended with all the changes that have been staged (such as files being added to the repository, modified, or deleted from the repository since the last snapshot).

Making a partial snapshot, which involves staging only some of your changes to your source code, is beyond the scope of this blog post. Our goal here is strictly to be able to rewind time with Git. So, each time we make a snapshot, we’re going to stage all the changes to our source code — that is, we’re going to stage any .java file that’s been added or modified since the last snapshot.

We won’t bother staging the .class files, because those can be regenerated with the Java compiler. (For students more familiar with C, the .java files are equivalent to the .c and .h files, and the .class files are equivalent to the compiled .o files and the executable.)

To see a list of all the files that have been added, modified, or deleted in the assignment’s directory, use the git status command. Here, we see that there are four new files in the directory that Git knows nothing about:

% git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	Barn.class
	Barn.java
	Cow.class
	Cow.java

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

To stage both of the new .java files, use the git add command:

% git add *.java

If you like, rerun the git status command to see the change in state of the .java files — now, they’re going to be included (or committed) in the next snapshot:

% git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   Barn.java
	new file:   Cow.java

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

Note that, if you make further changes to a .java file before you commit the changes in the next step, you’ll have to rerun

% git add *.java

to re-stage the latest changes.

Committing the Changes

With all of our .java files staged, we can now commit those changes. Committing all of the staged files is what creates a new snapshot in time.

Every time we make a commit (that is, make a new snapshot), we’ll attach a message to it describing the changes that have been made. This message is going to help us if we want to rewind time, by giving us a description of what was changed between each snapshot.

This message should be short — no more than 50 characters. It should also be written in the imperative, as if telling someone to do the task that was completed prior to this snapshot being created. For our upcoming snapshot, we added the Cow.java and Barn.java files, so our imperative command could be, “Add the Cow and Barn classes”.

To create a commit (that is, a snapshot) with this message, run:

% git commit -m "Add the Cow and Barn classes"

We can rerun the git status command to verify that there haven’t been any modifications to the .java files since the snapshot we just made:

% git status
On branch main
Untracked files:
  [...]

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

Making Another Commit

Later in this blog post, we’ll see how to view a log of all of our commits — that is, a log of each of our snapshots. To make this blog post more meaningful, we’ll make another quick commit to our repository. Let’s update the Cow.java file by adding a constructor to the class:

public class Cow
{
    public Cow() {}
}

Running the git status command shows us that the file has been changed, under the “Changes not staged for commit” heading:

% 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:   Cow.java

Untracked files:
  [...]

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

We can stage and commit this change, again using the imperative form of a short commit message:

% git add *.java
% git commit -m "Add constructor to the Cow class"

(The git add command ignores unmodified files, allowing us to use the glob *.java to stage all .java files that have been modified.)

Viewing the Log

Let’s assume that we want to rewind time on our assignment, or see what’s changed from a previous time. How do we know which old snapshot we should be looking at? To figure that out, we view the log for our repository:

% git log

When I run this command, I see the following (the individual hash codes — the long hexadecimal strings at the top of each commit — will be different for your repository):

commit c16769a43f68203d53888706ad70efbfc35eb246 (HEAD -> main)
Author: [...]
Date:   [...]

    Add constructor to the Cow class

commit 4e1509a7b75662c99f863eb12adc95e991ff9f43
Author: [...]
Date:   [...]

    Add the Cow and Barn classes

commit eaa6d70f9b6c882bdae347c94d3c048f26de93ae
Author: [...]
Date:   [...]

    First commit

To see the log with abbreviated hashes (which generally work just as well, and are easier to read), run:

% git log --abbrev-commit

That command produces:

commit c16769a (HEAD -> main)
Author: [...]
Date:   [...]

    Add constructor to the Cow class

commit 4e1509a
Author: [...]
Date:   [...]

    Add the Cow and Barn classes

commit eaa6d70
Author: [...]
Date:   [...]

    First commit

Viewing Changes Between Snapshots

The git diff command can be used to see changes between the way our code looks now, and the way our code looked in any previous snapshot. Let’s make another change to the Cow.java file, without staging or committing it, which introduces an error into the code:

public class Cow
{
    public Cow()
    {
        syntax-error
    }
}

To see the changes between our code as it appears now, and the code as it appeared in the most recent snapshot (before the error was introduced), run:

% git diff

The output shows us that one line of code has been deleted (indicated with a - sign), and four new ones added below where it used to be (indicated with a + sign):

diff --git a/Cow.java b/Cow.java
[...]
 public class Cow
 {
-    public Cow() {}
+    public Cow()
+    {
+        syntax-error
+    }
 }

To see all the changes between our code as it appears now, and the code as it appeared in any previous snapshot, we need the hash for that snapshot. For example, to view the changes between the current version of our code and the snapshot after we added the Cow and Barn classes, run:

% git diff 4e1509a

(where 4e1509a is the hash of the commit with the short message “Add the Cow and Barn classes“). That command shows us that between then and now, one line of code was deleted and seven new ones added:

-public class Cow {}
+public class Cow
+{
+    public Cow()
+    {
+        syntax-error
+    }
+}

Notice here that

% git diff

is equivalent to

% git diff c16769a

(where c16769a is the hash of the most recent snapshot in our repository).

We can also see what changed between any two snapshots. We may want to do this, for example, to try to track down how long ago an error was introduced in our code. To do this, we need, first, the hash of the older snapshot; then, second, the hash of the newer snapshot. Running

% git diff eaa6d70 4e1509a

will show us what changed between our snapshot with the short message “First commit“, and the snapshot with the short message “Add the Cow and Barn classes“. Two files were added, each with one line of code:

diff --git a/Barn.java b/Barn.java
new file mode 100644
[...]
+++ b/Barn.java
[...]
+public class Barn {}
diff --git a/Cow.java b/Cow.java
new file mode 100644
[...]
+++ b/Cow.java
[...]
+public class Cow {}

While the two hashes in this example are only one commit apart, there could be any number of commits between the two hashes in this git diff command.

Reverting to the Most Recent Snapshot

When we want to revert to a previous point in time, the first step is to create a new snapshot of the way the code looks right now (broken as it is). We do that, instead of directly reverting to the older snapshot (with a command called git restore), because we might later regret losing the code we’ve been working on since the last snapshot. Since our Cow class no longer compiles, let’s run:

% git add *.java
% git commit -m "Break compilation of the Cow class"

Then, we use the git revert command to roll back to the previous snapshot:

% git revert --no-edit HEAD
[main d1a2463] Revert "Break compilation of the Cow class"
 [...]

We can see that all of our .java files have rolled back in time to how they looked before we introduced the syntax error. The Cow.java file again looks like:

public class Cow
{
    public Cow() {}
}

When we run

% git log

we see that the git revert command also created a new snapshot of this moment, following the revert:

commit d1a24633c104e353d0224484a6882dc203631c45 (HEAD -> main)
Author: [...]
Date:   [...]

    Revert "Break compilation of the Cow class"
    
    This reverts commit 37b5467221129641635543a735ed27b1a5b8bca6.

commit 37b5467221129641635543a735ed27b1a5b8bca6
Author: [...]
Date:   [...]

    Break compilation of the Cow class

commit c16769a43f68203d53888706ad70efbfc35eb246
Author: [...]
Date:   [...]

    Add constructor to the Cow class

[...]

So, if we want to rewind time again to this point, we have a new snapshot (d1a2463) to roll back to.

One final note is that, while our .java files have rolled back in time, our .class files have not (since Git is only tracking the .java files we’ve staged and committed). After rolling back time, be sure to run:

% rm -f *.class

Reverting Further Back in Time

Perhaps we want to revert our assignment code back even further. As an example, let’s rewind to the point after we added the Cow and Barn classes, but before we added the constructor to Cow. We’re going to look in the log for the commit with the short message “Add the Cow and Barn classes“, which should come right before the commit with the short message “Add constructor to the Cow class“:

commit d1a2463 (HEAD -> main)
Author: [...]
Date:   [...]

    Revert "Break compilation of the Cow class"
    
    This reverts commit 37b5467221129641635543a735ed27b1a5b8bca6.

commit 37b5467
Author: [...]
Date:   [...]

    Break compilation of the Cow class

commit c16769a
Author: [...]
Date:   [...]

    Add constructor to the Cow class

commit 4e1509a
Author: [...]
Date:   [...]
    Add the Cow and Barn classes

[...]

Helpfully, the hash we’re looking for appears right between those two commit messages: 4e1509a.

Then, we use the git revert command to roll back to that point in time. The final argument is the hash, three periods, and the word HEAD in all capital letters:

% git revert -n --no-edit 4e1509a...HEAD

After running this git revert command, we can see that all of our .java files have rolled back in time to how they looked before the constructor was added to the Cow class. Cow.java again looks like:

public class Cow {}

Now, we can stage and commit the new state of our project, post-revert:

% git add *.java
% git commit -m "Revert to before constructor added to Cow class"

If you like, you can run

% git log

to show the commit we made after the most recent git revert command. The whole log is probably easiest read from the bottom up:

commit f7b1ceac322a2f8fe21282bee36aa07a934cc212 (HEAD -> main)
Author: [...]
Date:   [...]

    Revert to before constructor added to Cow class

commit d1a24633c104e353d0224484a6882dc203631c45
Author: [...]
Date:   [...]

    Revert "Break compilation of the Cow class"
    
    This reverts commit 37b5467221129641635543a735ed27b1a5b8bca6.

commit 37b5467221129641635543a735ed27b1a5b8bca6
Author: [...]
Date:   [...]

    Break compilation of the Cow class

commit c16769a43f68203d53888706ad70efbfc35eb246
Author: [...]
Date:   [...]

    Add constructor to the Cow class

commit eaa6d70f9b6c882bdae347c94d3c048f26de93ae
Author: [...]
Date:   [...]

    First commit

Notice that none of our history has been lost. We could, for example, undo our rewind of time by reverting to any previous commit — including the one that was created by the git revert command itself.

The .gitignore File

Bringing this blog post full circle, let’s return to the empty .gitignore file. This file can be filled with a list of files for Git to ignore when we’re staging. In our current repository, we wanted to stage only the .java files, and not stage the .class files. The easiest way to enforce that we don’t want to stage .class files is to add the following line to the .gitignore file:

*.class

We can stage and commit this file,

% git add .gitignore
% git commit -m "Add .class files to .gitignore"

and we no longer have to worry about accidentally staging a .class file.

The behaviour of the git status command has now changed — it no longer bothers to report the untracked .class files:

% touch Cow.class
% git status
On branch main
nothing to commit, working tree clean

Additionally, attempting to stage files that should be ignored results in a warning:

% git add Cow.class
The following paths are ignored by one of your .gitignore files:
Cow.class
hint: Use -f if you really want to add them.
hint: Turn this message off by running
hint: "git config advice.addIgnoredFile false"

Because Git is now ignores all .class files, we can stage all the non-.class files in our working directory by running

% git add .

(where . refers to the current directory), instead of having to run the more specific

% git add *.java

Adding the types of files you don’t want to stage to your .gitignore file makes it that much easier to quickly make snapshots of any modifications you do want saved.

Summary

Git is a powerful tool that can be used, for example, to create multiple branches of a codebase or work collaboratively with others. But, one simpler and very valuable use of Git is to look back through snapshots of your work on an assignment or project, and rewind time across those snapshots as well.

By initializing a Git repository at any time (git init), we can stage files (git add) and commit snapshots (git commit) to our repository. We can then look back through history (git log), and even rewind time (git revert).

I highly recommend students initialize a Git repository in their working directory for each assignment or project they’re working on. By using Git, hopefully you’ll never wind up in a situation where you know your code worked ten minutes ago, but you can’t figure out what you’ve changed in the last ten minutes.

For more tips, and to arrange for personalized tutoring for yourself or your study group, check out Vancouver Computer Science Tutoring.