diff --git a/.gitignore b/.gitignore
index 42ef840fe..d6b15ef10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,4 +25,5 @@ lazygit
 !.circleci/
 !.github/
 
-test/git_server/data
\ No newline at end of file
+test/git_server/data
+test/integration_test/
diff --git a/pkg/gui/gui_test.go b/pkg/gui/gui_test.go
new file mode 100644
index 000000000..d0a22710a
--- /dev/null
+++ b/pkg/gui/gui_test.go
@@ -0,0 +1,224 @@
+package gui
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"github.com/go-errors/errors"
+	"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+	"github.com/stretchr/testify/assert"
+)
+
+// To run an integration test, e.g. for test 'commit', go:
+// go test pkg/gui/gui_test.go -run /commit
+//
+// To record keypresses for an integration test, pass RECORD_EVENTS=true like so:
+// RECORD_EVENTS=true go test pkg/gui/gui_test.go -run /commit
+//
+// To update a snapshot for an integration test, pass UPDATE_SNAPSHOT=true
+// UPDATE_SNAPSHOT=true go test pkg/gui/gui_test.go -run /commit
+//
+// When RECORD_EVENTS is true, updates will be updated automatically
+//
+// integration tests are run in test/integration_test and the final test does
+// not clean up that directory so you can cd into it to see for yourself what
+// happened when a test failed.
+//
+// TODO: support passing an env var for playback speed, given it's currently pretty fast
+
+type integrationTest struct {
+	name    string
+	prepare func() error
+}
+
+func generateSnapshot(t *testing.T) string {
+	osCommand := oscommands.NewDummyOSCommand()
+	cmd := `sh -c "git status; cat ./*; git log --pretty=%B -p"`
+
+	snapshot, err := osCommand.RunCommandWithOutput(cmd)
+	assert.NoError(t, err)
+
+	return snapshot
+}
+
+func findOrCreateDir(path string) {
+	_, err := os.Stat(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			err = os.MkdirAll(path, 0777)
+			if err != nil {
+				panic(err)
+			}
+		} else {
+			panic(err)
+		}
+	}
+}
+
+func Test(t *testing.T) {
+	tests := []integrationTest{
+		{
+			name:    "commit",
+			prepare: createFixture1,
+		},
+		{
+			name:    "squash",
+			prepare: createFixture2,
+		},
+	}
+
+	gotoRootDirectory()
+
+	rootDir, err := os.Getwd()
+	if err != nil {
+		panic(err)
+	}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			testPath := filepath.Join(rootDir, "test", "integration", test.name)
+			findOrCreateDir(testPath)
+
+			replayPath := filepath.Join(testPath, "recording.json")
+			snapshotPath := filepath.Join(testPath, "snapshot.txt")
+
+			err := os.Chdir(rootDir)
+			assert.NoError(t, err)
+
+			prepareIntegrationTestDir()
+
+			err = test.prepare()
+			assert.NoError(t, err)
+
+			record := os.Getenv("RECORD_EVENTS") != ""
+			runLazygit(t, replayPath, record)
+
+			updateSnapshot := os.Getenv("UPDATE_SNAPSHOT") != ""
+
+			actual := generateSnapshot(t)
+
+			if updateSnapshot {
+				err := ioutil.WriteFile(snapshotPath, []byte(actual), 0600)
+				assert.NoError(t, err)
+			}
+
+			expectedBytes, err := ioutil.ReadFile(snapshotPath)
+			assert.NoError(t, err)
+			expected := string(expectedBytes)
+
+			assert.Equal(t, expected, actual, fmt.Sprintf("expected:\n%s\nactual:\n%s\n", expected, actual))
+		})
+	}
+}
+
+func createFixture1() error {
+	cmds := []string{
+		"git init",
+		`sh -c "echo test > myfile"`,
+	}
+
+	return runCommands(cmds)
+}
+
+func createFixture2() error {
+	cmds := []string{
+		"git init",
+		`sh -c "echo test1 > myfile1"`,
+		`git add .`,
+		`git commit -am "myfile1"`,
+		`sh -c "echo test2 > myfile2"`,
+		`git add .`,
+		`git commit -am "myfile2"`,
+		`sh -c "echo test3 > myfile3"`,
+		`git add .`,
+		`git commit -am "myfile3"`,
+		`sh -c "echo test4 > myfile4"`,
+		`git add .`,
+		`git commit -am "myfile4"`,
+		`sh -c "echo test5 > myfile5"`,
+		`git add .`,
+		`git commit -am "myfile5"`,
+	}
+
+	return runCommands(cmds)
+}
+
+func runCommands(cmds []string) error {
+	osCommand := oscommands.NewDummyOSCommand()
+
+	for _, cmd := range cmds {
+		if err := osCommand.RunCommand(cmd); err != nil {
+			return errors.New(fmt.Sprintf("error running command `%s`: %v", cmd, err))
+		}
+	}
+
+	return nil
+}
+
+func gotoRootDirectory() {
+	for {
+		_, err := os.Stat(".git")
+
+		if err == nil {
+			return
+		}
+
+		if !os.IsNotExist(err) {
+			panic(err)
+		}
+
+		if err = os.Chdir(".."); err != nil {
+			panic(err)
+		}
+	}
+}
+
+func runLazygit(t *testing.T, replayPath string, record bool) {
+	osCommand := oscommands.NewDummyOSCommand()
+
+	var cmd *exec.Cmd
+	if record {
+		cmd = osCommand.ExecutableFromString("lazygit")
+		cmd.Env = append(
+			cmd.Env,
+			fmt.Sprintf("RECORD_EVENTS_TO=%s", replayPath),
+		)
+	} else {
+		cmd = osCommand.ExecutableFromString("lazygit")
+		cmd.Env = append(
+			cmd.Env,
+			fmt.Sprintf("REPLAY_EVENTS_FROM=%s", replayPath),
+		)
+	}
+	err := osCommand.RunExecutable(cmd)
+	assert.NoError(t, err)
+}
+
+func prepareIntegrationTestDir() {
+	path := filepath.Join("test", "integration_test")
+
+	// remove contents of integration test directory
+	dir, err := ioutil.ReadDir(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			err = os.Mkdir(path, 0777)
+			if err != nil {
+				panic(err)
+			}
+		} else {
+			panic(err)
+		}
+	}
+	for _, d := range dir {
+		os.RemoveAll(filepath.Join(path, d.Name()))
+	}
+
+	if err := os.Chdir(path); err != nil {
+		panic(err)
+	}
+}
diff --git a/pkg/gui/recording.go b/pkg/gui/recording.go
index 5e0914303..57bf27948 100644
--- a/pkg/gui/recording.go
+++ b/pkg/gui/recording.go
@@ -9,7 +9,11 @@ import (
 )
 
 func recordingEvents() bool {
-	return os.Getenv("RECORD_EVENTS") == "true"
+	return recordEventsTo() != ""
+}
+
+func recordEventsTo() string {
+	return os.Getenv("RECORD_EVENTS_TO")
 }
 
 func (gui *Gui) timeSinceStart() int64 {
@@ -29,11 +33,14 @@ func (gui *Gui) replayRecordedEvents() {
 	ticker := time.NewTicker(time.Millisecond)
 	defer ticker.Stop()
 
-	var leeway int64 = 1000
+	// might need to add leeway if this ends up flakey
+	var leeway int64 = 0
+	// humans are slow so this speeds things up.
+	var speed int64 = 5
 
 	for _, event := range events {
 		for range ticker.C {
-			now := gui.timeSinceStart() - leeway
+			now := gui.timeSinceStart()*speed - leeway
 			if gui.g != nil && now >= event.Timestamp {
 				gui.g.ReplayedEvents <- *event.Event
 				break
@@ -70,7 +77,9 @@ func (gui *Gui) saveRecordedEvents() error {
 		return err
 	}
 
-	return ioutil.WriteFile("recorded_events.json", jsonEvents, 0600)
+	path := recordEventsTo()
+
+	return ioutil.WriteFile(path, jsonEvents, 0600)
 }
 
 func (gui *Gui) recordEvents() {
diff --git a/test/integration/commit/recording.json b/test/integration/commit/recording.json
new file mode 100644
index 000000000..d362b0271
--- /dev/null
+++ b/test/integration/commit/recording.json
@@ -0,0 +1,226 @@
+[
+  {
+    "Timestamp": 41,
+    "Event": {
+      "Type": 1,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 0,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 0,
+      "Bytes": null
+    }
+  },
+  {
+    "Timestamp": 1042,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 32,
+      "Ch": 0,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "IA=="
+    }
+  },
+  {
+    "Timestamp": 1602,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 99,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "Yw=="
+    }
+  },
+  {
+    "Timestamp": 2010,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 109,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "bQ=="
+    }
+  },
+  {
+    "Timestamp": 2170,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 121,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "eQ=="
+    }
+  },
+  {
+    "Timestamp": 2234,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 32,
+      "Ch": 0,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "IA=="
+    }
+  },
+  {
+    "Timestamp": 2354,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 99,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "Yw=="
+    }
+  },
+  {
+    "Timestamp": 2410,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 111,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "bw=="
+    }
+  },
+  {
+    "Timestamp": 2578,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 109,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "bQ=="
+    }
+  },
+  {
+    "Timestamp": 2690,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 109,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "bQ=="
+    }
+  },
+  {
+    "Timestamp": 2730,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 105,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "aQ=="
+    }
+  },
+  {
+    "Timestamp": 2850,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 116,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "dA=="
+    }
+  },
+  {
+    "Timestamp": 2954,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 13,
+      "Ch": 0,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "DQ=="
+    }
+  },
+  {
+    "Timestamp": 3625,
+    "Event": {
+      "Type": 0,
+      "Mod": 0,
+      "Key": 0,
+      "Ch": 113,
+      "Width": 0,
+      "Height": 0,
+      "Err": null,
+      "MouseX": 0,
+      "MouseY": 0,
+      "N": 1,
+      "Bytes": "cQ=="
+    }
+  }
+]
diff --git a/test/integration/commit/snapshot.txt b/test/integration/commit/snapshot.txt
new file mode 100644
index 000000000..0c50ae828
--- /dev/null
+++ b/test/integration/commit/snapshot.txt
@@ -0,0 +1,13 @@
+On branch master
+nothing to commit, working tree clean
+test
+my commit
+
+
+diff --git a/myfile b/myfile
+new file mode 100644
+index 0000000..9daeafb
+--- /dev/null
++++ b/myfile
+@@ -0,0 +1 @@
++test
diff --git a/test/integration/squash/recording.json b/test/integration/squash/recording.json
new file mode 100644
index 000000000..5bb4996b7
--- /dev/null
+++ b/test/integration/squash/recording.json
@@ -0,0 +1 @@
+[{"Timestamp":14,"Event":{"Type":1,"Mod":0,"Key":0,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":0,"Bytes":null}},{"Timestamp":482,"Event":{"Type":0,"Mod":0,"Key":65514,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09D"}},{"Timestamp":626,"Event":{"Type":0,"Mod":0,"Key":65514,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09D"}},{"Timestamp":810,"Event":{"Type":0,"Mod":0,"Key":65516,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09C"}},{"Timestamp":937,"Event":{"Type":0,"Mod":0,"Key":65516,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09C"}},{"Timestamp":1065,"Event":{"Type":0,"Mod":0,"Key":65516,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09C"}},{"Timestamp":1591,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":101,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"ZQ=="}},{"Timestamp":2034,"Event":{"Type":0,"Mod":0,"Key":65517,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09B"}},{"Timestamp":2243,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":115,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"cw=="}},{"Timestamp":2554,"Event":{"Type":0,"Mod":0,"Key":65517,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09B"}},{"Timestamp":2803,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":100,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"ZA=="}},{"Timestamp":3209,"Event":{"Type":0,"Mod":0,"Key":65517,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":3,"Bytes":"G09B"}},{"Timestamp":3522,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":102,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"Zg=="}},{"Timestamp":4066,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":109,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"bQ=="}},{"Timestamp":5091,"Event":{"Type":0,"Mod":0,"Key":13,"Ch":0,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"DQ=="}},{"Timestamp":5834,"Event":{"Type":0,"Mod":0,"Key":0,"Ch":113,"Width":0,"Height":0,"Err":null,"MouseX":0,"MouseY":0,"N":1,"Bytes":"cQ=="}}]
\ No newline at end of file
diff --git a/test/integration/squash/snapshot.txt b/test/integration/squash/snapshot.txt
new file mode 100644
index 000000000..445bf0f96
--- /dev/null
+++ b/test/integration/squash/snapshot.txt
@@ -0,0 +1,42 @@
+On branch master
+nothing to commit, working tree clean
+test1
+test2
+test3
+test5
+myfile2
+
+myfile3
+
+
+diff --git a/myfile2 b/myfile2
+new file mode 100644
+index 0000000..180cf83
+--- /dev/null
++++ b/myfile2
+@@ -0,0 +1 @@
++test2
+diff --git a/myfile3 b/myfile3
+new file mode 100644
+index 0000000..df6b0d2
+--- /dev/null
++++ b/myfile3
+@@ -0,0 +1 @@
++test3
+diff --git a/myfile5 b/myfile5
+new file mode 100644
+index 0000000..4f346f1
+--- /dev/null
++++ b/myfile5
+@@ -0,0 +1 @@
++test5
+myfile1
+
+
+diff --git a/myfile1 b/myfile1
+new file mode 100644
+index 0000000..a5bce3f
+--- /dev/null
++++ b/myfile1
+@@ -0,0 +1 @@
++test1