Repository: git-keeper/git-keeper (language: python)

Issue:
Bump version to 0.3.1

$HOME variable incorrect in test action.sh
The `$HOME` variable is incorrect during action.sh scripts. The user is the `tester` user but `$HOME` points to `/home/keeper`. Some Python imports (like matplotlib) use the `$HOME/.config` directory for saving things even when just importing them, and since that directory is inaccessible generate a warning in the generated email. The `$USER` variable is correct though, so the following workaround is available:

```bash
export HOME=$(getent passwd "$USER" | cut -d: -f6)
```

If you are using `sudo` to run the program as `tester`, using the `-H` or `--set-home` option to `sudo` fixes this issue.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 33f38994..5f632447 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ dist/
 log.html
 report.html
 output.xml
+*.log
+.DS_Store
+.venv
+site
diff --git a/VERSION b/VERSION
index 0d91a54c..9e11b32f 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-0.3.0
+0.3.1
diff --git a/docs/admin/faculty_csv.md b/docs/admin/faculty_csv.md
new file mode 100644
index 00000000..8fe0e6d8
--- /dev/null
+++ b/docs/admin/faculty_csv.md
@@ -0,0 +1,5 @@
+## gkeepd's faculty.csv
+
+`<last name>,<first name>,<email address>`
+
+
diff --git a/docs/admin/server-setup.md b/docs/admin/server-setup.md
new file mode 100644
index 00000000..0cb230b8
--- /dev/null
+++ b/docs/admin/server-setup.md
@@ -0,0 +1,255 @@
+# Server Setup
+
+This guide details how to set up a server to run git-keeper. The guide assumes
+you are starting with a Debian Jessie minimal netinstall
+installation. git-keeper should work with any Linux distribution, but the setup
+details might be slightly different.
+
+## Quick Start
+
+Here is an overview of the steps necessary to get a git-keeper server up and
+running. Follow these if you have done it before and just need a
+refresher. Skip to the next section for more detailed instructions.
+
+These instructions assume you have a dedicated server with a Debian
+installation and a working SSH server.
+
+* Create the following users:
+    * `keeper`. Must be in the `keeper` group.
+* Add a line to `/etc/ssh/sshd_config` to disallow the user `keeper` to log in via SSH: `DenyUsers keeper`
+* Install `sudo`, `python3`, and `git`
+* Clone the git-keeper repository and install both `git-keeper-core` and
+`git-keeper-server`
+* Configure `sudo` so that `keeper` can run any command without a password
+* Create `server.cfg`
+* Create a user systemd service to automatically start gkeepd
+* Add faculty members
+
+### Requirements
+
+git-keeper requires a server running a Linux operating system which is
+dedicated to running git-keeper. The server must allow incoming SSH traffic 
+and it must have access to an SMTP server. 
+
+The guide assumes you have installed Debian from the minimal network
+installation media and that you have enabled the SSH server.
+
+Other Linux distributions will work as well but some of the following steps may
+be a little different.
+
+### Create the Keeper User
+
+The `keeper` user is the user that will run the git-keeper server
+process. **This user will have root privileges.**
+
+Create the user. Be sure to choose a strong password:
+ 
+```
+adduser keeper
+```
+
+Ensure that `keeper` is also in the group `keeper`. This command shows the
+groups that `keeper` is in:
+
+```
+groups keeper
+```
+
+### Configure SSH
+
+The `keeper` user has elevated privileges, so nobody should be able to SSH to
+the server as that user. Add this line to `/etc/ssh/sshd_config` to prevent
+this:
+ 
+```
+DenyUsers keeper
+```
+
+Now restart the SSH server:
+
+```
+systemctl restart ssh.service
+```
+
+### Install Dependencies
+
+Install the following packages:
+
+```
+apt-get install python3 sudo git
+```
+
+### Configure `sudo`
+
+The `keeper` user needs to be able to run a number of commands as root and
+the tester user. To allow this, create a `sudo` configuration for `keeper`:
+
+```
+visudo -f /etc/sudoers.d/keeper
+```
+
+Add the following line to the configuration:
+
+```
+keeper ALL = (ALL) NOPASSWD: ALL
+```
+
+### Install the Server Package
+
+The git-keeper server can be installed using `pip` like so:
+
+```
+sudo python3 -m pip install git-keeper-server
+```
+
+### Create `server.cfg`
+
+There must be a file named `server.cfg` in the `keeper` user's home
+directory. This is the configuration file for the server.
+
+`server.cfg` has four sections: `[server]`, `[email]`, `[admin]`, and `[gkeepd]`.
+
+#### `[server]`
+
+The `[server]` section has a single required parameter `hostname`, which is the
+hostname of the server. This will be used to build Git URLs.
+
+#### `[email]`
+
+The `[email]` section is also required. The following parameters are required:
+
+```
+from_name: <name that the emails will be from>
+from_address: <email address that the emails come from>
+smtp_server: <hostname of the SMTP server>
+smtp_port: <SMTP server port>
+```
+
+The following parameters are optional:
+
+```
+use_tls: <true or false, defaults to true>
+email_username: <username for the SMTP server>
+email_password: <password for the SMTP server>
+email_interval: <seconds to wait between sending emails>
+use_html: <true or false, defaults to false>
+```
+
+If `use_html` is true, submission test results will be sent as an HTML email,
+placing the contents within pre tags so that they appear in a fixed width font.
+
+#### `[admin]`
+
+The `[admin]` section is also required. The section defines an admin user. The admin user will automatically be added as a faculty user when the server first starts, and then the admin user will be able to add additional faculty members.
+
+The following parameters are required:
+
+```
+admin_email: <email address of the admin user>
+admin_first_name: <first name of the admin user>
+admin_last_name: <last name of the admin user>
+```
+
+#### `[gkeepd]`
+
+The `[gkeepd]` section is optional. Below are the allowed parameters and their
+defaults:
+
+```
+tests_timeout: 300
+tests_memory_limit: 1024
+```
+
+The `tests_timeout` parameter specifies a global timeout for tests in case an assignment's tests fail to properly account for infinite loops. The default is 300 seconds. If this timeout occurs the student and faculty member will be notified that there was an error in the tests, so one should not depend on this timeout to cleanly catch infinite loops.
+
+Similar to `tests_timeout`, the `tests_memory_limit` sets a global memory limit for tests. The default limit is 1024 MB.
+
+#### Template
+
+Here is a template `server.cfg`. Required parameters must be defined, optional
+parameters are commented out with their default values, if they exist.
+
+```
+[server]
+hostname: 
+
+[email]
+from_name: 
+from_address: 
+smtp_server: 
+smtp_port: 
+#use_tls: true
+#email_username: 
+#email_password: 
+#email_interval: 2
+
+[admin]
+admin_email: 
+admin_first_name: 
+admin_last_name: 
+
+#[gkeepd]
+#tests_timeout: 300
+#tests_memory_limit: 1024
+```
+
+### Using a `systemd` service
+
+You can run `gkeepd` in a `screen` or `tmux` session but it is recommended that
+you run `gkeepd` as a `systemd` service so that it automatically starts on
+boot.
+
+#### Creating the Service
+
+Create the file `/etc/systemd/system/gkeepd.service` with the
+following contents:
+
+```
+[Unit]
+Description=git-keeper server
+
+[Service]
+Type=simple
+User=keeper
+Group=keeper
+ExecStart=/usr/local/bin/gkeepd
+
+[Install]
+WantedBy=default.target
+```
+
+This assumes that `gkeepd` was installed at `/usr/local/bin/gkeepd`. Type
+`which gkeepd` to see where the executable is on your system and adjust the
+path if necessary.
+
+
+#### Enabling and Starting the Service
+
+Enable:
+
+```
+systemctl enable gkeepd
+```
+
+Start:
+
+```
+systemctl start gkeepd
+```
+
+Check that it is running:
+
+```
+systemctl status gkeepd
+```
+
+
+You can also look at `~keeper/gkeepd.log` to check on the status of the daemon.
+
+### Adding Faculty Members
+
+Once the server is running, the admin user can use the [client](Client-setup) to add additional faculty members like so:
+
+```
+gkeep add_faculty <last name> <first name> <email address>
+```
\ No newline at end of file
diff --git a/docs/client_setup.md b/docs/client_setup.md
deleted file mode 100644
index 1dad4502..00000000
--- a/docs/client_setup.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Client Setup
-
-## Requirements
-
-The client requires the following software:
-
-* Python >= 3.4
-* pip
-
-## Installation
-
-Install the client with pip:
-
-```
-pip install git-keeper-client
-```
-
-## Configuration
-
-TODO
diff --git a/docs/develop/actionrunner-interface.md b/docs/develop/actionrunner-interface.md
new file mode 100644
index 00000000..72443224
--- /dev/null
+++ b/docs/develop/actionrunner-interface.md
@@ -0,0 +1,53 @@
+This is a working draft of an interface for a class `ActionRunner` which can run a sequence of actions to test a student submission.
+
+## Constructor
+
+The `ActionRunner` constructor has no required parameters. It has the following optional parameters:
+
+* `timeout` (default `10`): Testing will run for at most `timeout` number of seconds, at which time it will kill the tests and print an error message.
+* `timeout_message` (default `'Testing your submission took too long. Maybe you have an infinite loop?'`): The error message to print when there is a timeout.
+* `memlimit` (default `1024`): Testing will use at most `memlimit` megabytes of memory, at which time it will kill the tests and print an error message.
+* `memlimit_message` (default `'Testing your submission used too much memory.`'): The error message to print when testing uses too much memory.
+* `working_dir` (defaults to the current working directory): The path to the directory in which to run the tests.
+* `submission_dir` (defaults to `sys.argv[1]`): The path to the directory containing the student's submission.
+
+## Action Methods
+
+These methods add actions to the `ActionRunner`'s internal list of actions. The actions will not be run immediately, but they will be run in the order in which they are added.
+
+* `files_exist(required_files, optional_files=None, error_message='Error: {0} does not exist.', warning_message='Warning: {0} does not exist.')`: Check that files exist in `submission_dir`
+    * `required_files`: An iterable containing filenames relative to `submission_dir` which must exist. If any do not exist, an error message is printed and all action halts.
+    * `optional_files`: An optional iterable containing filenames relative to `submission_dir` for which a warning will be printed if any of the files do not exist. Action will not halt.
+    * `error_message`: The error message to print if a required file does not exist. Must contain `{}` or `{0}` so that the filename can be inserted with `error_message.format(filename)`.
+    * `warning_message`: The warning message to print if an optional file does not exist. Must contain `{}` or `{0}` so that the filename can be inserted with `error_message.format(filename)`.
+* `copy_files(filenames, dest=None)`: Copy files from `submission_dir`
+    * `filenames`: An iterable containing relative paths to the filenames to copy from `submission_dir`
+    * `dest`: The directory to copy into. This will be `working_dir` if not provided. If provided it will be treated as a relative path to `working_dir` which will be created if it does not already exist.
+* `copy_dirs(directories, dest=None)`: Copy directories from `submission_dir`
+    * `directories`: An iterable containing relative paths to the directories to copy from `submission_dir`
+    * `dest`: The directory to copy into. This will be `working_dir` if not provided. If provided it will be treated as a relative path to `working_dir` which will be created if it does not already exist.
+* `run_command(command, output=True, error_is_fatal=True, error_message='Error running tests:\n\n{0}')`: Run a shell command
+    * `command`: The command to run as a list or a string
+    * `output`: If True, print the output of the command. If False, do nothing with the output. If it is a string, treat it as a filename relative to `working_dir` and write the output to that file
+    * `bad_output_pattern`: A regular expression suitable for Python's `re` module. If the pattern matches, treat it the same as if the command exited with a non-zero status
+    * `error_is_fatal`: If True and the command returns a non-zero exit code, halt all actions
+    * `error_message`: The error message to print if the command returns a non-zero exit code. If the command contains a formatting specifier (`{}` or `{0}`) then the output of the command will be inserted into the message.
+* `diff(filename1, filename2, output=False, different_is_fatal=True, different_message='', same_message='', directory=None)`: Determine if the contents of 2 files are different or the same
+    * `filename1`: Relative path of the first file
+    * `filename2`: Relative path of the second file
+    * `output`: Throws the output away if false, prints it if True, writes it to a file if it is a string
+    * `different_is_fatal`: If True, all actions will halt if the files are different
+    * `different_message`: Message to print if the files' contents are different
+    * `same_message`: Message to print if the files' contents are the same
+* `search(pattern, filename, dir=None)`: Try to match a regular expression against the contents of a file
+    * `pattern`: A regular expression suitable for use with the Python's `re` module
+    * `filename`: The relative path of the file in which to search
+    * `dir`: The path of the directory where the file resides. Uses `working_dir` if not provided.
+    * TODO: optional parameters to define behavior on a match or mismatch
+
+## Run Method
+
+After calling one or more action methods, call `run()` to actually run all the actions.
+
+* `run(success_message='All tests passed!')`
+    * `success_message`: The message to print if all actions have been run with no errors
\ No newline at end of file
diff --git a/docs/developers.md b/docs/develop/developers.md
similarity index 100%
rename from docs/developers.md
rename to docs/develop/developers.md
diff --git a/docs/develop/info-json-structure.md b/docs/develop/info-json-structure.md
new file mode 100644
index 00000000..b040a336
--- /dev/null
+++ b/docs/develop/info-json-structure.md
@@ -0,0 +1,38 @@
+Many client operations fetch the contents of the latest info file from the server. An info file contains information about a faculty member's classes and assignments. Here is the structure of an example info file:
+
+```json
+{
+   "class_name":{
+      "assignments":{
+         "assignment_name":{
+            "name":"assignment_name",
+            "published":true,
+            "reports_repo":{
+               "hash":"202585432b8ff21ff4f93a886fff9b09c46eb18e",
+               "path":"/home/faculty/classes/class_name/assignment_name/reports.git"
+            },
+            "students_repos":{
+               "alovelace":{
+                  "first":"Ada",
+                  "hash":"76f608acf4d08ccd1bf07955e2600bdce3f80774",
+                  "last":"Lovelace",
+                  "path":"/home/alovelace/faculty/class_name/assignment_name.git",
+                  "submission_count":0,
+                  "time":1476986981
+               }
+            }
+         }
+      },
+      "students":{
+         "alovelace":{
+            "email_address":"alovelace@example.edu",
+            "first":"Ada",
+            "home_dir":"/home/alovelace",
+            "last":"Lovelace",
+            "last_first_username":"lovelace_ada_alovelace",
+            "username":"alovelace"
+         }
+      }
+   }
+}
+```
\ No newline at end of file
diff --git a/docs/develop/log-events.md b/docs/develop/log-events.md
new file mode 100644
index 00000000..1fa001dd
--- /dev/null
+++ b/docs/develop/log-events.md
@@ -0,0 +1,29 @@
+Events are written to event logs. Each line of the log is an event, and is structured like this:
+
+```
+<timestamp> <event type> <payload>
+```
+
+For example:
+
+```
+1463892789 SUBMISSION /home/student/faculty/class/assignment.git
+```
+
+Event types are used to determine what event handler should handle the event.
+
+## Student log events
+
+* `SUBMISSION <student assignment repo path>` - Student pushes to an assignment repository. Triggers test running.
+
+## Faculty log events
+
+* `UPLOAD <assignment directory>` - Faculty uploads a new assignment.
+* `UPDATE <assignment directory> <item>` - Faculty updates an assignment item.
+    * Items:
+        * `base_code`
+        * `tests`
+* `PUBLISH <assignment directory>` - Faculty requests that the assignment be published.
+* `DELETE <assignment directory>` - Faculty requests that the assignment be deleted.
+* `CLASS_ADD` <class directory>` - Faculty adds a class roster.
+* `CLASS_MODIFY` <class directory>` - Faculty modifies a class roster.
\ No newline at end of file
diff --git a/docs/release_checklist.md b/docs/develop/release_checklist.md
similarity index 100%
rename from docs/release_checklist.md
rename to docs/develop/release_checklist.md
diff --git a/docs/develop/server_filesystem_structure.md b/docs/develop/server_filesystem_structure.md
new file mode 100644
index 00000000..39a23b40
--- /dev/null
+++ b/docs/develop/server_filesystem_structure.md
@@ -0,0 +1,38 @@
+```
+/
+└── home
+    ├── faculty
+    │   ├── faculty
+    │   │   └── example_class
+    │   │       └── hw01.git
+    │   └── .gitkeeper
+    │       ├── classes
+    │       │   └── example_class
+    │       │       └── hw01
+    │       │           ├── base_code.git
+    │       │           ├── email.txt
+    │       │           ├── reports.git
+    │       │           └── tests
+    │       ├── faculty.log
+    │       ├── gkeepd.log
+    │       ├── info
+    │       │   └── 1576784041.3908958.json
+    │       └── uploads
+    │           └── 1576784036.5428793
+    │               └── students.csv
+    ├── keeper
+    │   ├── .gitconfig
+    │   ├── gkeepd.lock
+    │   ├── gkeepd.log
+    │   ├── gkeepd_db.sqlite
+    │   └── server.cfg
+    ├── student
+    │   ├── faculty
+    │   │   └── example_class
+    │   │       └── hw01.git
+    │   ├── .gitkeeper
+    │   │   └── student.log
+    │   └── git-shell-commands
+    │       └── passwd
+    └── tester
+```
\ No newline at end of file
diff --git a/docs/faculty/assignment-workflow.md b/docs/faculty/assignment-workflow.md
new file mode 100644
index 00000000..134ebd94
--- /dev/null
+++ b/docs/faculty/assignment-workflow.md
@@ -0,0 +1,233 @@
+What follows is a guide to creating, uploading, and publishing assignments and
+then fetching the students' submissions.
+
+## Assignment Structure
+
+To create an assignment, first create a directory to contain the
+assignment. The name of the directory will be the name of the assignment.
+
+### `base_code`
+
+Within the assignment directory there must be a directory named
+`base_code`. The contents of this directory will be the intial contents of the
+student's repository for the assignment. Put skeleton code, data files,
+instructions, etc. in this directory.
+
+### `email.txt`
+
+There must also be a file named `email.txt`. The email that students receive
+when a new assignment is published will always contain a clone URL, and the
+contents of `email.txt` will be appended to the email after the
+URL. `email.txt` can be blank.
+
+### `tests`
+
+Lastly there must be a directory called `tests` which must contain a shell
+script named `action.sh`. The `tests` directory also contains any other code
+and data files that you will use to test student code.
+
+When a student submits an assignment to the server it creates a temporary clone
+of the student's repository and and temporary copy of the tests directory. The
+server then enters the temporary `tests` directory and runs `action.sh` using
+`bash`, passing it the path to the temporary clone of the student's directory.
+
+This means that before you upload the assignment you can test your tests
+locally by entering the `tests` directory in the terminal and running
+`action.sh` on the path to a solution. It is convenient to also store a
+`solution` directory in the assignment directory for testing. This way you can
+run the following from inside the `tests` directory to test your tests against
+your solution:
+
+```
+bash action.sh ../solution
+```
+
+The output of `action.sh` (both stdout and stderr) are placed in the email that
+the student receives as feedback.
+
+## Creating `action.sh`
+
+For some assignments you can write all your tests in bash and so `action.sh` is
+the only file you need in the `tests` directory.
+
+For other assignments you may wish to write your tests in another language or
+to use a testing framework such as JUnit to test the student code. In that case
+you can simply call your tests from `action.sh`.
+
+## Example
+
+Here is an example where the tests are written entirely in `action.sh`.
+
+Let's say you want to create an assignment for which students will write a
+Python program to print `"Hello, world!"`. You want them to write this program
+in an initially empty file named `hello_world.py` and for them to receive
+instructions by email.
+
+You might create an assignment structure like this:
+
+```
+hw01-hello_world
+├── base_code
+│   └── hello_world.py
+├── email.txt
+├── solution
+│   └── hello_world.py
+└── tests
+    ├── action.sh
+    └── expected_output.txt
+```
+
+where `base_code/hello_world.py` is an empty file, `email.txt` contains
+instructions for the assignment, `solution/hello_world.py` contains your
+solution, and `tests/expected_output.txt` contains `"Hello, world!"`
+
+What's left is to write `action.sh`. You want to run the student's
+`hello_world.py` and compare the output to `expected_output.txt`. Here is a
+simple way to go about it:
+
+```bash
+# The path to the student's submission directory is in $1
+SUBMISSION_DIR=$1
+
+# Run the student's code and put the output (stdout and stderr) in output.txt
+python $SUBMISSION_DIR/hello_world.py &> output.txt
+
+# Compare the output with the expected output. Throw away the diff output
+# because we only care about diff's exit code
+diff output.txt expected_output.txt &> /dev/null
+
+# diff returns a non-zero exit code if the files were different
+if [ $? -ne 0 ]
+then
+    echo "Your program did not produce the expected output. Your output:"
+    echo
+    cat output.txt
+    echo
+    echo "Expected output:"
+    echo
+    cat expected_output.txt
+else
+    echo "Tests passed, good job!"
+fi
+
+# Always exit 0. If action.sh exits with a non-zero exit code the server sees
+# this as an error in your tests.
+exit 0
+```
+
+### Time and Memory Limits
+
+Student code containing infinite loops is troublesome. Let's add the use of
+`timeout` and `ulimit` to the example to catch infinite loops or excessive
+memory use:
+
+```bash
+# Use a 10-second timeout
+TIMEOUT=10
+# Limit memory use to 400 MB
+MEM_LIMIT_MB=400
+
+# ulimit uses KB
+MEM_LIMIT_KB=$(($MEM_LIMIT_MB * 1024))
+
+# Set the memory limit
+ulimit -v $MEM_LIMIT_KB
+
+# The path to the student's submission directory is in $1
+SUBMISSION_DIR=$1
+
+# Run the student's code and put the output (stdout and stderr) in output.txt.
+# If execution takes more than 10 seconds the process will be killed
+timeout $TIMEOUT python $SUBMISSION_DIR/hello_world.py &> output.txt
+
+# timeout returns an exit code of 124 on a timeout, otherwise it returns the
+# exit code of the process it ran
+if [ $? -eq 124 ]
+then
+    echo "Your program ran for too long. Perhaps you have an infinite loop?"
+    exit 0
+fi
+
+# Compare the output with the expected output. Throw away the diff output
+# because we only care about diff's exit code
+diff output.txt expected_output.txt &> /dev/null
+
+# diff returns a non-zero exit code if the files were different
+if [ $? -ne 0 ]
+then
+    echo "Your program did not produce the expected output. Your output:"
+    echo
+    cat output.txt
+    echo
+    echo "Expected output:"
+    echo
+    cat expected_output.txt
+else
+    echo "Tests passed, good job!"
+fi
+
+# Always exit 0. If action.sh exits with a non-zero exit code the server sees
+# this as an error in your tests.
+exit 0
+```
+
+## Uploading an Assignment
+
+Let's assume you have created an assignment named `hw01-hello_world`, as in the
+example above, which is for the class `CS100`. You can upload the assignment to
+the server like so:
+
+```
+gkeep upload CS100 hw01-hello_world
+```
+
+Uploading an assignment does not immediately send it to your students. If the
+assignment uploads successfully you will receive an email that looks just like
+the email that the students get when they receive the assignment. You can make
+sure the assignment works as expected by cloning it and pushing some solutions.
+
+If you discover errors in your assignment you can update it before sending it
+to the students. You can modify the any one of the three components of the
+assignment individually, or update everything. To update the entire assignment:
+
+```
+gkeep update CS100 hw01-hello_world all
+```
+
+Run `gkeep update` without additional arguments for more info.
+
+## Publishing an Assignment
+
+Once you are satisfied with your uploaded assignment you can publish it, which
+will send it out to students:
+
+```
+gkeep publish CS100 hw01-hello_world
+```
+
+Once an assignment is published you cannot update the base code or the email,
+but you can update the tests.
+
+## Fetching Submissions
+
+When you want to grade your students' submissions, you can fetch them with
+`gkeep fetch`. If you defined `submissions_path` in the `[local]` section of
+`client.cfg` then `gkeep` can fetch all your assignments into a common
+directory. Otherwrise you need to specify a directory to fetch to.
+
+This will fetch into `submissions_path`:
+
+```
+gkeep fetch CS100 hw01-hello_world
+```
+
+Fetch into the current directory:
+
+```
+gkeep fetch CS100 hw01-hello_world
+```
+
+The fetched directory for the assigment contains 2 subdirectories: `reports`
+and `submissions`. `reports` contains the text of the emails that your students
+received when they submitted. The `submissions` directory contains the code
+that your students submitted.
diff --git a/docs/faculty/client-setup.md b/docs/faculty/client-setup.md
new file mode 100644
index 00000000..6a663e36
--- /dev/null
+++ b/docs/faculty/client-setup.md
@@ -0,0 +1,91 @@
+# Client Setup
+
+This guide details how to setup a faculty machine to use the git-keeper
+client. The client has been used successfully in OS X and Linux. It may work
+in Windows using Cygwin or the Windows Subsystem for Linux but that has not been tested.
+
+## Requirements
+
+The client requires Git and Python3.
+
+### OS X
+
+If you run `git` in a terminal it will prompt you to install the Xcode tools
+(which contain Git) if they are not already installed.
+
+Download and install Python3.
+
+The `cryptography` package is required to use SSH through Python. Once Python3
+is installed, install the `cryptography` package using `pip3`:
+
+```
+pip3 install cryptography
+```
+
+### Linux
+
+These dependencies should be straightforward to install on a Linux system
+through your distribution's package manager.
+
+## Installing the Client
+
+To install the client system-wide, run the following command:
+
+```
+sudo python3 -m pip install git-keeper-client
+```
+
+Alternatively, you may want to install the client in a Python virtual environment if you do not like to clutter your system's Python packages.
+
+## Configuration
+
+There must be a configuration file at `~/.config/git-keeper/client.cfg`.
+
+`client.cfg` requires a section called `[server]` which defines the hostname of
+the server, your faculty username on the server, and optionally the SSH
+server's port number which defaults to 22.
+
+There is also an optional section `[local]` in which you can define a directory
+into which `gkeep` will fetch submissions.  If present, this *must* be an absolute path.
+
+Here is an example `client.cfg`:
+
+```
+[server]
+host = gitkeeper.myhostname.com
+username = myfacultyusername
+# optional
+ssh_port = 22
+
+# optional section
+[local]
+submissions_path = ~/submissions
+```
+
+## SSH Key
+
+The client communicates with the server over SSH. You will need to copy your
+SSH public key to the git-keeper server so that you can make SSH connections
+without a password.
+
+If you have never generated a public key, do so now:
+
+```
+ssh-keygen
+```
+
+All the defaults should be fine, you can press enter repeatedly to step through
+the menus.
+
+Now you need to copy your public key to your git-keeper server. If your faculty
+username on the server is `<username>` and the hostname of your server is
+`<hostname>`, run this:
+
+```
+ssh-copy-id <username>@<hostname>
+```
+
+## Usage
+
+Now you should be able to run `gkeep` commands. Running `gkeep` without any
+arguments will display a usage message.
diff --git a/docs/faculty/create-class.md b/docs/faculty/create-class.md
new file mode 100644
index 00000000..6dd3c2d0
--- /dev/null
+++ b/docs/faculty/create-class.md
@@ -0,0 +1,22 @@
+To create a class you first need to create a CSV file with the names and email
+addresses of the students in the class.
+
+To create a class named `CS100`, create a file named `CS100.csv` containing
+something like the following:
+
+```
+Hamilton,Margaret,mhamilton@example.edu
+Hopper,Grace,ghopper@example.edu
+Lovelace,Ada,alovelace@example.edu
+```
+
+Now you can add the class on the server using `gkeep`:
+
+```
+gkeep add CS110 CS110.csv
+```
+
+An Account will be created for any student in the class that does not already
+have an account on the server. The account name will be the username portion of
+the student's email address. Passwords are randomly generated. An email
+containing the student's username and password will be sent to the student.
diff --git a/docs/index.md b/docs/index.md
index 874e10af..8e6c829b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,3 +1,19 @@
-# git-keeper Documentation
 
-Fancy description.
+## Admin
+
+* [Server Setup](admin/server-setup.md)
+
+## Faculty
+
+* [Client Setup](faculty/client-setup.md)
+* [Create a Class](faculty/create-class.md)
+* [Assignment Workflow](faculty/assignment-workflow.md)
+
+## Developers
+
+* [General Guidelines](develop/developers.md)
+* [Release Checklist](develop/release_checklist.md)
+* [`ActionRunner` Interface](develop/actionrunner-interface.md)
+* [JSON Structure](develop/info-json-structure.md)
+* [Log Structure](develop/log-events.md)
+* [Server File Structure](develop/server_filesystem_structure.md)
diff --git a/docs/server_setup.md b/docs/server_setup.md
deleted file mode 100644
index 04f33e8f..00000000
--- a/docs/server_setup.md
+++ /dev/null
@@ -1,153 +0,0 @@
-# Server Setup
-
-This guide details how to set up a server to run git-keeper. The guide assumes
-you are starting with a Debian Jessie minimal netinstall
-installation. git-keeper should work with any Linux distribution, but the setup
-details might be slightly different.
-
-## Quick Start
-
-Here is an overview of the steps necessary to get a git-keeper server up and
-running. Follow these if you have done it before and just need a refresher, or
-you are experienced with Linux system administration and do not need specific
-instructions. Skip to the next section for more detailed instructions.
-
-These instructions assume you have a dedicated server with a Debian
-installation and a working SSH server.
-
-* Create the following users:
-    * `keeper`. Must be in the `keeper` group.
-    * One account for each faculty member. Add the accounts to the `keeper`
-    group.
-* Edit `/etc/ssh/sshd_config`
-    * Remove ecdsa and ed25519 host keys. They do not work with paramiko.
-    * Disallow the user `keeper` to log in via SSH: `DenyUsers keeper`
-* Install `python3`, `python3-pip`, `libffi-dev`, `libssl-dev`, and `sudo`
-* Install `git-keeper` using `pip3`
-* Configure `sudo` so that `keeper` can run `useradd`, `usermod`, `chpasswd`,
-`groupadd`, `chown`, and `chmod` without a password.
-
-
-### Requirements
-
-git-keeper requires a server running a Linux operating system which is
-dedicated to running git-keeper.
-
-A minimal Debian installation provides a stripped down and stable system which
-is well suited to running git-keeper. Tests are run within Docker containers,
-so any dependencies required for testing student code will be handled by Docker.
- 
-The guide assumes you have installed Debian from the minimal network
-installation media and that you have enabled the SSH server.
-
-Other Linux distributions will work as well but some of the following steps may
-be a little different.
-
-### Create Users
-
-As root, you will need to create at least 2 users.
-
-#### Keeper User
-
-The `keeper` user is the user that will run the git-keeper server process. This
-user will have elevated privileges.
- 
-Create the user. Be sure to choose a strong password:
- 
-```
-adduser keeper
-```
-
-Ensure that `keeper` is also in the group `keeper`. This command shows the
-groups that `keeper` is in:
-
-```
-groups keeper
-```
-
-#### Faculty Users
-
-Each faculty member that will be using git-keeper needs an account. For this
-guide we will assume there is only one faculty member, and we will create the
-user `faculty` for this person to use.
-
-Create the user:
-
-```
-adduser faculty
-```
-
-Add the user to the `keeper` group:
-
-```
-usermod -a -G keeper faculty
-```
-
-Repeat for each additional faculty member.
-
-### Configure SSH
-
-Edit the file `/etc/ssh/sshd_config`. You can edit it with the editor `nano`
-like so:
-
-```
-nano /etc/ssh/sshd_config
-```
-
-git-keeper clients use paramiko for communication over SSH, and paramiko only
-works with RSA and DSA host keys. So you will need to comment the others
-out. Edit the HostKeys section so that it looks like this:
-
-```
-HostKey /etc/ssh/ssh_host_rsa_key
-HostKey /etc/ssh/ssh_host_dsa_key
-#HostKey /etc/ssh/ssh_host_ecdsa_key
-#HostKey /etc/ssh/ssh_host_ed25519_key
-```
-
-The `keeper` user has elevated privileges, so nobody should be able to SSH to
-the server as that user. Add this line to the file to prevent this:
- 
-```
-DenyUsers keeper
-```
-
-Now restart the SSH server:
-
-```
-systemctl restart ssh.service
-```
-
-### Install Software
-
-Install the following Debian packages:
-
-```
-apt-get install python3 python3-pip libffi-dev libssl-dev sudo
-```
-
-Install git-keeper:
-
-```
-pip3 install git-keeper
-```
-
-### Configure `sudo`
-
-The `keeper` user needs to be able to run a number of commands as root. Create
-a `sudo` configuration for `keeper` by running this command:
-
-```
-visudo -f /etc/sudoers.d/keeper
-```
-
-Add the following lines to the configuration:
-
-```
-keeper ALL = (root) NOPASSWD: /usr/sbin/useradd
-keeper ALL = (root) NOPASSWD: /usr/sbin/usermod
-keeper ALL = (root) NOPASSWD: /usr/sbin/chpasswd
-keeper ALL = (root) NOPASSWD: /usr/sbin/groupadd
-keeper ALL = (root) NOPASSWD: /bin/chown
-keeper ALL = (root) NOPASSWD: /bin/chmod
-```
diff --git a/git-keeper-client/gkeepclient/server_actions.py b/git-keeper-client/gkeepclient/server_actions.py
index 26ae79db..bdffb578 100644
--- a/git-keeper-client/gkeepclient/server_actions.py
+++ b/git-keeper-client/gkeepclient/server_actions.py
@@ -56,14 +56,16 @@ def class_add(class_name: str, csv_file_path: str, yes: bool):
             raise GkeepException('{0} does not exist'.format(csv_file_path))
 
         students = students_from_csv(LocalCSVReader(csv_file_path))
+    else:
+        students = []
 
+    if len(students) > 0:
         print('Class {0} will be added with the following students:'
               .format(class_name))
 
         for student in students:
             print(student)
     else:
-        students = []
         print('Class {} will be added with no students.'.format(class_name))
         print('You may add students at a later time using gkeep modify.')
 
diff --git a/git-keeper-client/gkeepclient/version.py b/git-keeper-client/gkeepclient/version.py
index cbc028cc..da6aa3b7 100644
--- a/git-keeper-client/gkeepclient/version.py
+++ b/git-keeper-client/gkeepclient/version.py
@@ -2,4 +2,4 @@
 # Instead, edit the VERSION file in the top level directory of the git-keeper
 # project and then run bump_version.py.
 
-__version__ = '0.3.0'
+__version__ = '0.3.1'
diff --git a/git-keeper-core/gkeepcore/git_commands.py b/git-keeper-core/gkeepcore/git_commands.py
index bdaebfd0..4b557cd5 100644
--- a/git-keeper-core/gkeepcore/git_commands.py
+++ b/git-keeper-core/gkeepcore/git_commands.py
@@ -18,8 +18,7 @@
 
 import os
 
-from gkeepcore.shell_command import run_command_in_directory, run_command, \
-    CommandError
+from gkeepcore.shell_command import run_command, CommandError
 
 
 def git_remote_add(repo_path, remote_name, url):
@@ -32,8 +31,9 @@ def git_remote_add(repo_path, remote_name, url):
     :param remote_name: name of the remote
     :param url: URL of the remote
     """
-    cmd = ['git', 'remote', 'add', remote_name, url]
-    run_command_in_directory(repo_path, cmd)
+
+    cmd = ['git', '-C', repo_path, 'remote', 'add', remote_name, url]
+    run_command(cmd)
 
 
 def git_init(repo_path):
@@ -45,8 +45,8 @@ def git_init(repo_path):
     :param repo_path: path to the directory in which to initialize the
      repository
     """
-    cmd = ['git', 'init']
-    run_command_in_directory(repo_path, cmd)
+    cmd = ['git', '-C', repo_path, 'init']
+    run_command(cmd)
 
 
 def git_init_bare(repo_path):
@@ -58,8 +58,8 @@ def git_init_bare(repo_path):
     :param repo_path: path to the directory in which to initialize the
      repository
     """
-    cmd = ['git', 'init', '--bare']
-    run_command_in_directory(repo_path, cmd)
+    cmd = ['git', '-C', repo_path, 'init', '--bare']
+    run_command(cmd)
 
 
 def git_add_all(repo_path):
@@ -70,8 +70,8 @@ def git_add_all(repo_path):
 
     :param repo_path: path to the repository
     """
-    cmd = ['git', 'add', '-A']
-    run_command_in_directory(repo_path, cmd)
+    cmd = ['git', '-C', repo_path, 'add', '-A']
+    run_command(cmd)
 
 
 def git_commit(repo_path, message):
@@ -83,11 +83,43 @@ def git_commit(repo_path, message):
     :param repo_path: path to the repository
     :param message: commit message
     """
-    cmd = ['git', 'commit', '-m', message]
-    run_command_in_directory(repo_path, cmd)
+    cmd = ['git', '-C', repo_path, 'commit', '-m', message]
+    run_command(cmd)
 
 
-def git_push(repo_path, dest=None, branch='master', force=False, sudo=False):
+def get_git_branch(repo_path):
+    """
+    Get the current branch of a git repository.
+
+    :param repo_path: path of the repository
+    :return: name of the current branch
+    """
+
+    cmd = ['git', '-C', repo_path, 'rev-parse', '--abbrev-ref', 'HEAD']
+    branch = run_command(cmd).strip()
+
+    return branch
+
+
+def git_unstaged_changes_exist(repo_path):
+    """
+    Determine if a git repository has unstaged changes.
+
+    :param repo_path: path of the repository
+    :return: True if unstaged changes exist, False otherwise
+    """
+
+    cmd = ['git', '-C', repo_path, 'diff', '--exit-code']
+
+    try:
+        run_command(cmd)
+        return False
+    except CommandError:
+        return True
+
+
+def git_push(repo_path, dest=None, branch=None, force=False, sudo=False,
+             user=None):
     """
     Push a repository to its upstream remote, or to a specific destination.
 
@@ -97,17 +129,21 @@ def git_push(repo_path, dest=None, branch='master', force=False, sudo=False):
     :param dest: optional destination to push to
     :param branch: optional branch, if dest is specified
     :param force: set to True to force a push (be careful!)
-    :param sudo: if true command is run as root
+    :param sudo: if true command is run as root or another user
+    :param user: if not none and sudo is True, the push is run as this user
     """
-    cmd = ['git', 'push']
+    cmd = ['git', '-C', repo_path, 'push']
 
     if force:
         cmd.append('-f')
 
     if dest is not None:
+        if branch is None:
+            branch = get_git_branch(repo_path)
+
         cmd += [dest, branch]
 
-    run_command_in_directory(repo_path, cmd, sudo=sudo)
+    run_command(cmd, sudo=sudo, user=user)
 
 
 def git_pull(repo_path, remote_url=None):
@@ -118,12 +154,12 @@ def git_pull(repo_path, remote_url=None):
     :param remote_url: URL to pull from, None to pull from upstream remote
     """
 
-    cmd = ['git', 'pull']
+    cmd = ['git', '-C', repo_path, 'pull']
 
     if remote_url is not None:
         cmd.append(remote_url)
 
-    run_command_in_directory(repo_path, cmd)
+    run_command(cmd)
 
 
 def git_config(repo_path, config_options):
@@ -135,10 +171,10 @@ def git_config(repo_path, config_options):
       config
     """
 
-    cmd = ['git', 'config']
+    cmd = ['git', '-C', repo_path, 'config']
     cmd.extend(config_options)
 
-    run_command_in_directory(repo_path, cmd)
+    run_command(cmd)
 
 
 def is_non_bare_repo(repo_path):
@@ -163,9 +199,9 @@ def git_clone(source_repo_path, target_path):
     :return: None
     """
 
-    cmd = ['git', 'clone', source_repo_path]
+    cmd = ['git', '-C', target_path, 'clone', source_repo_path]
 
-    run_command_in_directory(target_path, cmd)
+    run_command(cmd)
 
 
 def git_clone_remote(remote_repo_url, local_repo_path):
@@ -188,22 +224,25 @@ def git_checkout(repo_path, branch_or_commit):
     :param branch_or_commit: name of the branch or the commit hash to checkout
     """
 
-    cmd = ['git', 'checkout', branch_or_commit]
+    cmd = ['git', '-C', repo_path, 'checkout', branch_or_commit]
 
-    run_command_in_directory(repo_path, cmd)
+    run_command(cmd)
 
 
-def git_head_hash(repo_path):
+def git_head_hash(repo_path, user=None):
     """
     Get the hash of the HEAD of a git repository.
 
     :param repo_path: path to the repository
+    :param user: username of the owner of the repository
     :return: commit hash of HEAD
     """
 
-    cmd = ['git', 'rev-parse', 'HEAD']
+    cmd = ['git', '-C', repo_path, 'rev-parse', 'HEAD']
+
+    sudo = user is not None
 
-    return run_command_in_directory(repo_path, cmd).rstrip()
+    return run_command(cmd, sudo=sudo, user=user).rstrip()
 
 
 def git_head_hash_date(repo_path):
@@ -216,9 +255,9 @@ def git_head_hash_date(repo_path):
     :return: tuple containing the hash and the timestamp
     """
 
-    cmd = ['git', 'log', '-1', '--format=%H %at']
+    cmd = ['git', '-C', repo_path, 'log', '-1', '--format=%H %at']
 
-    output = run_command_in_directory(repo_path, cmd)
+    output = run_command(cmd)
     split_output = output.split()
 
     if len(split_output) != 2:
@@ -230,7 +269,7 @@ def git_head_hash_date(repo_path):
     return repo_hash, timestamp
 
 
-def git_hashes_and_times(repo_path):
+def git_hashes_and_times(repo_path, user=None):
     """
     Get the hashes and commit times of the commits to a git repository.
 
@@ -239,12 +278,15 @@ def git_hashes_and_times(repo_path):
     The hashes are strings.
 
     :param repo_path: path to the repository
+    :param user: the username of the repository owner
     :return: list containing (hash, time) tuples
     """
 
-    cmd = ['git', 'log', '--format=%H %at']
+    cmd = ['git', '-C', repo_path, 'log', '--format=%H %at']
+
+    sudo = user is not None
 
-    output = run_command_in_directory(repo_path, cmd)
+    output = run_command(cmd, sudo=sudo, user=user)
     lines = output.splitlines()
 
     hashes_and_times = []
diff --git a/git-keeper-core/gkeepcore/path_utils.py b/git-keeper-core/gkeepcore/path_utils.py
index 3085ff17..9038975b 100644
--- a/git-keeper-core/gkeepcore/path_utils.py
+++ b/git-keeper-core/gkeepcore/path_utils.py
@@ -112,6 +112,7 @@ def user_log_path(gitkeeper_path: str, username: str):
     <user .gitkeeper directory>/<username>.log
 
     :param gitkeeper_path: the path to the user's .gitkeeper directory
+    :param username: username of the log's owner
     :return: the user's log path
     """
 
@@ -190,28 +191,29 @@ def parse_submission_repo_path(path) -> (str, str, str, str):
     return faculty_username, class_name, assignment_name
 
 
-def parse_faculty_assignment_path(path) -> (str, str):
+def parse_faculty_assignment_path(path) -> (str, str, str):
     """
-    Extract the class name and assignment name from a path to a faculty
-    assignment directory.
+    Extract the faculty username, class name, and assignment name from a path
+    to a faculty assignment directory.
 
     :param path: path to the assignment directory
-    :return: a tuple containing the class name and the assignment name, or
-             None if the path is malformed
+    :return: a tuple containing the faculty username, the class name,
+     and the assignment name, or None if the path is malformed
     """
 
     # we're parsing a path that ends with this:
-    # <class>/<assignment>
+    # <faculty username>/.gitkeeper/classes/<class>/<assignment>
 
     path_list = path_to_list(path)
 
-    if len(path_list) < 2:
+    if len(path_list) < 5:
         return None
 
-    # the information we want is in the last 2 elements of the list
+    # the class name and assignment name are the last 2 elements of the list
     class_name, assignment_name = path_list[-2:]
+    faculty_username = path_list[-5]
 
-    return class_name, assignment_name
+    return faculty_username, class_name, assignment_name
 
 
 def parse_faculty_class_path(path) -> str:
diff --git a/git-keeper-core/gkeepcore/shell_command.py b/git-keeper-core/gkeepcore/shell_command.py
index 0f7a5cc2..bf25c1b8 100644
--- a/git-keeper-core/gkeepcore/shell_command.py
+++ b/git-keeper-core/gkeepcore/shell_command.py
@@ -32,7 +32,7 @@ class CommandError(GkeepException):
     pass
 
 
-def run_command(command, sudo=False, stderr=STDOUT) -> str:
+def run_command(command, sudo=False, user=None, stderr=STDOUT) -> str:
     """
     Run a shell command and return the output.
 
@@ -43,6 +43,7 @@ def run_command(command, sudo=False, stderr=STDOUT) -> str:
     :param command: a shell command as a string or a list of strings
      representing each argument
     :param sudo: set to True to run the command using sudo
+    :param user: if sudo is True, run as this user or root if None
     :param stderr: where to send stderr
     :return: the output of the command
 
@@ -53,12 +54,18 @@ def run_command(command, sudo=False, stderr=STDOUT) -> str:
         raise CommandError('command must be a string or a list, not {0}'
                            .format(type(command)))
 
-    # prepend sudo if need be
+    # prepend sudo and specify user if need be
     if sudo:
         if isinstance(command, str):
-            command = 'sudo -n ' + command
+            if user is None:
+                command = 'sudo -n ' + command
+            else:
+                command = 'sudo -n -u {}'.format(user) + command
         else:
-            command = ['sudo', '-n'] + command
+            if user is None:
+                command = ['sudo', '-n'] + command
+            else:
+                command = ['sudo', '-n', '-u', user] + command
 
     # run the command
     try:
@@ -81,6 +88,9 @@ class ChangeDirectoryContext:
     For use as a context manager to change into a directory and change out
     again.
 
+    THE WORKING DIRECTORY IS CHANGED AT THE PROCESS LEVEL, AND AS SUCH IT IS
+    NOT SAFE FOR MORE THAN ONE THREAD TO USE THIS CONTEXT MANAGER.
+
     Example:
 
         with ChangeDirectoryContext('path/to/dir'):
@@ -100,10 +110,14 @@ def __exit__(self, *args):
         os.chdir(self._old_wd)
 
 
-def run_command_in_directory(path, command, sudo=False, stderr=STDOUT):
+def run_command_in_directory(path, command, sudo=False, user=None,
+                             stderr=STDOUT):
     """
     Change into a new working directory, run a command, and change back.
 
+    THE WORKING DIRECTORY IS CHANGED AT THE PROCESS LEVEL, AND AS SUCH IT IS
+    NOT SAFE FOR MORE THAN ONE THREAD TO USE THIS FUNCTION.
+
     Raises CommandError if the command could not be called or has a non-zero
     exit code.
 
@@ -114,12 +128,13 @@ def run_command_in_directory(path, command, sudo=False, stderr=STDOUT):
     :param command: a shell command as a string or a list of strings
      representing each argument
     :param sudo: set to True to run the command using sudo
+    :param user: if sudo is True, run as this user or root if None
     :param stderr: where to send stderr
     :return: the output of the command
     """
     try:
         with ChangeDirectoryContext(path):
-            output = run_command(command, sudo=sudo, stderr=stderr)
+            output = run_command(command, sudo=sudo, user=user, stderr=stderr)
     except Exception as e:
         raise CommandError(e)
 
diff --git a/git-keeper-core/gkeepcore/version.py b/git-keeper-core/gkeepcore/version.py
index cbc028cc..da6aa3b7 100644
--- a/git-keeper-core/gkeepcore/version.py
+++ b/git-keeper-core/gkeepcore/version.py
@@ -2,4 +2,4 @@
 # Instead, edit the VERSION file in the top level directory of the git-keeper
 # project and then run bump_version.py.
 
-__version__ = '0.3.0'
+__version__ = '0.3.1'
diff --git a/git-keeper-robot/gkeeprobot/keywords/ClientSetupKeywords.py b/git-keeper-robot/gkeeprobot/keywords/ClientSetupKeywords.py
index c101457b..f6b6d6e1 100644
--- a/git-keeper-robot/gkeeprobot/keywords/ClientSetupKeywords.py
+++ b/git-keeper-robot/gkeeprobot/keywords/ClientSetupKeywords.py
@@ -16,6 +16,8 @@
 
 from gkeeprobot.control.ClientControl import ClientControl
 from gkeeprobot.control.ServerControl import ServerControl
+from gkeeprobot.control.VMControl import ExitCodeException
+from gkeeprobot.exceptions import GkeepRobotException
 
 """Provides keywords for robotframework to configure faculty and student
 accounts before testing begins."""
@@ -87,14 +89,36 @@ def clone_assignment(self, student, faculty, class_name, assignment_name):
         command = 'git clone {} assignments/{}/{}'.format(url, class_name, assignment_name)
         client_control.run(student, command)
 
-    def student_submits_correct_solution(self, student, faculty, class_name, assignment_name):
-        assignment_folder = '~/assignments/{}/{}'.format(class_name, assignment_name)
-        cp_cmd = 'cp /vagrant/assignments/{}/correct_solution/* {}'.format(assignment_name, assignment_folder)
+    def student_submits(self, student, faculty, class_name, assignment_name,
+                        solution_folder, expect_failure=False, branch=None):
+        assignment_folder = '~/assignments/{}/{}'.format(class_name,
+                                                         assignment_name)
+        cp_cmd = ('cp /vagrant/assignments/{}/{}/* {}'
+                  .format(assignment_name, solution_folder, assignment_folder))
         client_control.run(student, cp_cmd)
+
+        if branch is not None:
+            new_branch_cmd = ('cd {}; git checkout -b {}'
+                              .format(assignment_folder, branch))
+            client_control.run(student, new_branch_cmd)
+
         commit_cmd = 'cd {}; git commit -am "done"'.format(assignment_folder)
         client_control.run(student, commit_cmd)
-        push_cmd = 'cd {}; git push origin master'.format(assignment_folder)
-        client_control.run(student, push_cmd)
+
+        if branch is None:
+            push_cmd = 'cd {}; git push'.format(assignment_folder)
+        else:
+            push_cmd = 'cd {}; git push origin {}'.format(assignment_folder,
+                                                          branch)
+
+        try:
+            client_control.run(student, push_cmd)
+            if expect_failure:
+                raise GkeepRobotException('Expected push to have a non-zero '
+                                          'exit code')
+        except ExitCodeException as e:
+            if not expect_failure:
+                raise e
 
     def fetch_assignment(self, faculty, class_name, assignment_name, target_dir=None):
         if target_dir is not None:
diff --git a/git-keeper-robot/gkeeprobot/keywords/ServerCheckKeywords.py b/git-keeper-robot/gkeeprobot/keywords/ServerCheckKeywords.py
index 7d182cf4..610ce7bc 100644
--- a/git-keeper-robot/gkeeprobot/keywords/ServerCheckKeywords.py
+++ b/git-keeper-robot/gkeeprobot/keywords/ServerCheckKeywords.py
@@ -65,6 +65,16 @@ def new_assignment_email_exists(self, username, course_name, assignment_name):
             raise GkeepRobotException('No new assignment email exists for {}, {}, {}'.format(username, course_name,
                                                                                    assignment_name))
 
+    def new_assignment_email_does_not_exist(self, username, course_name, assignment_name):
+        # assignment name is in the body of the message
+        subject = '"[{}] New assignment: {}"'.format(course_name, assignment_name)
+        result = control.run_vm_python_script('keeper', 'email_to.py',
+                                              username, subject,
+                                              assignment_name)
+        if result == 'True':
+            raise GkeepRobotException('New assignment email exists for {}, {}, {}'.format(username, course_name,
+                                                                                   assignment_name))
+
     def submission_test_results_email_exists(self, username, course_name, assignment_name, body_contains):
         subject = '"[{}] {} submission test results"'.format(course_name, assignment_name)
         result = control.run_vm_python_script('keeper', 'email_to.py',
@@ -82,6 +92,14 @@ def submission_test_results_email_does_not_exist(self, username, course_name, as
             raise GkeepRobotException('Submission test result email exists for {}, {}, {}'.format(username, course_name,
                                                                                     assignment_name))
 
+    def verify_submission_test_result_count(self, username, course_name, assignment_name, body_contains, count):
+        subject = '"[{}] {} submission test results"'.format(course_name, assignment_name)
+        result = control.run_vm_python_script('keeper', 'email_to_count.py',
+                                              username, subject, body_contains, count)
+        if result != 'True':
+            raise GkeepRobotException('Did not see {} submission test result emails for {}, {}, {}'
+                                      .format(count, username, course_name, assignment_name))
+
     def email_exists(self, to_user, subject_contains=None, body_contains=None):
         result = control.run_vm_python_script('keeper', 'email_to.py',
                                               to_user, subject_contains, body_contains)
diff --git a/git-keeper-server/gkeepserver/assignments.py b/git-keeper-server/gkeepserver/assignments.py
index a53e3025..9b0a971f 100644
--- a/git-keeper-server/gkeepserver/assignments.py
+++ b/git-keeper-server/gkeepserver/assignments.py
@@ -22,7 +22,6 @@
 
 from gkeepcore.action_scripts import get_action_script_and_interpreter
 from gkeepcore.student import Student
-from gkeepcore.upload_directory import UploadDirectory
 from pkg_resources import resource_exists, resource_string, ResolutionError, \
     ExtractionError
 
@@ -37,7 +36,7 @@
 from gkeepserver.database import db
 from gkeepserver.email_sender_thread import email_sender
 from gkeepserver.server_configuration import config
-from gkeepserver.server_email import Email, EmailException
+from gkeepserver.server_email import Email, EmailException, EmailPriority
 
 
 class StudentAssignmentError(GkeepException):
@@ -70,7 +69,6 @@ class AssignmentDirectory:
         base_code_repo_path - path to the base code repository
         reports_repo_path - path to the reports repository
         tests_path - path to the tests directory
-        action_sh_path - path to action.sh
     """
 
     def __init__(self, path, check=True):
@@ -84,7 +82,6 @@ def __init__(self, path, check=True):
         :param check: whether or not to check if everything exists
         """
         self.path = path
-        self.run_action_sh_path = os.path.join(self.path, 'run_action.sh')
         self.email_path = os.path.join(self.path, 'email.txt')
         self.base_code_repo_path = os.path.join(self.path, 'base_code.git')
         self.reports_repo_path = os.path.join(self.path, 'reports.git')
@@ -96,10 +93,12 @@ def __init__(self, path, check=True):
         path_info = parse_faculty_assignment_path(path)
 
         if path_info is None:
+            self.faculty_username = None
             self.class_name = None
             self.assignment_name = None
         else:
-            self.class_name, self.assignment_name = path_info
+            self.faculty_username, self.class_name, self.assignment_name = \
+                path_info
 
         if check:
             self.check()
@@ -189,7 +188,7 @@ def create_base_code_repo(assignment_dir: AssignmentDirectory,
     Create a bare repository in an assignment directory which contains the
     base code for an assignment, a post-receive hook script for triggering
     tests, and a pre-receive hook for ensuring the students only push to
-    the master branch. The repository is configured to reject destructive
+    the one existing branch. The repository is configured to reject destructive
     pushes.
 
     Raises an AssignmentDirectoryError if anything goes wrong.
@@ -267,45 +266,6 @@ def copy_tests_dir(assignment_dir: AssignmentDirectory, tests_path: str):
     cp(tests_path, assignment_dir.tests_path, recursive=True, sudo=True)
 
 
-def write_run_action_sh(assignment_dir: AssignmentDirectory,
-                        upload_dir: UploadDirectory):
-    """
-    Write run_action.sh into an assignment directory.
-
-    The contents of the script depend on the type of action script used in the
-    uploaded assignment.
-
-    :param assignment_dir: AssignmentDirectory to write to
-    :param upload_dir: UploadDirectory containing uploaded assignment data
-    """
-    temp_dir = TemporaryDirectory()
-    temp_dir_path = temp_dir.name
-
-    temp_run_action_sh_path = os.path.join(temp_dir_path, 'run_action.sh')
-
-    template = '''#!/bin/bash
-GLOBAL_TIMEOUT={global_timeout}
-GLOBAL_MEM_LIMIT_MB={global_memory_limit}
-GLOBAL_MEM_LIMIT_KB=$(($GLOBAL_MEM_LIMIT_MB * 1024))
-ulimit -v $GLOBAL_MEM_LIMIT_KB
-trap 'kill -INT -$pid' INT
-timeout $GLOBAL_TIMEOUT {interpreter} {script_name} "$@" &
-pid=$!
-wait $pid
-'''
-
-    run_action_sh_contents = \
-        template.format(global_timeout=config.tests_timeout,
-                        global_memory_limit=config.tests_memory_limit,
-                        interpreter=upload_dir.action_script_interpreter,
-                        script_name=upload_dir.action_script)
-
-    with open(temp_run_action_sh_path, 'w') as f:
-        f.write(run_action_sh_contents)
-
-    mv(temp_run_action_sh_path, assignment_dir.path, sudo=True)
-
-
 def remove_student_assignment(assignment_dir: AssignmentDirectory,
                               student, faculty_username: str):
     """
@@ -442,9 +402,15 @@ def setup_student_assignment(assignment_dir: AssignmentDirectory,
     # clone URL followed by email.txt contents
     email_body = 'Clone URL:\n{0}\n\n{1}'.format(clone_url, email_body)
 
+    if student.username != faculty_username:
+        priority = EmailPriority.LOW
+    else:
+        priority = EmailPriority.HIGH
+
     # build the email
     try:
-        email = Email(student.email_address, email_subject, email_body)
+        email = Email(student.email_address, email_subject, email_body,
+                      priority=priority)
     except EmailException as e:
         raise StudentAssignmentError(e)
 
diff --git a/git-keeper-server/gkeepserver/create_user.py b/git-keeper-server/gkeepserver/create_user.py
index f73d76dc..1e502b89 100644
--- a/git-keeper-server/gkeepserver/create_user.py
+++ b/git-keeper-server/gkeepserver/create_user.py
@@ -19,13 +19,18 @@
 import os
 from enum import Enum
 from shutil import which
+from tempfile import TemporaryDirectory
+
+from pkg_resources import resource_exists, resource_string, ResolutionError, \
+    ExtractionError
 
 from gkeepcore.gkeep_exception import GkeepException
 from gkeepcore.path_utils import user_home_dir, user_log_path, \
     gkeepd_to_faculty_log_path, user_gitkeeper_path
 from gkeepcore.student import Student
 from gkeepcore.system_commands import (sudo_add_user, sudo_set_password, chmod,
-                                       sudo_chown, mkdir, make_symbolic_link)
+                                       sudo_chown, mkdir, make_symbolic_link,
+                                       mv)
 from gkeepserver.database import db
 from gkeepserver.email_sender_thread import email_sender
 from gkeepserver.faculty import Faculty
@@ -34,7 +39,7 @@
 from gkeepserver.initialize_log import initialize_log
 from gkeepserver.log_polling import log_poller
 from gkeepserver.server_configuration import config
-from gkeepserver.server_email import Email
+from gkeepserver.server_email import Email, EmailPriority
 
 
 class UserType(Enum):
@@ -114,10 +119,39 @@ def create_faculty_dirs(username: str):
     sudo_chown(uploads_dir_path, username, config.keeper_group)
 
 
-def create_git_shell_commands(username: str, command_list: list):
+def write_git_shell_command_script(script, dest_path, username):
+    script_data_path = 'data/{}'.format(script)
+
+    if not resource_exists('gkeepserver', script_data_path):
+        raise GkeepException('Data sript {} does not exist'
+                             .format(script_data_path))
+
+    try:
+        script_text = resource_string('gkeepserver', script_data_path)
+        script_text = script_text.decode()
+    except (ResolutionError, ExtractionError, UnicodeDecodeError):
+        raise GkeepException('error reading {} script data'.format(script))
+
+    script_tempdir = TemporaryDirectory()
+    temp_script_path = os.path.join(script_tempdir.name, script)
+
+    try:
+        with open(temp_script_path, 'w') as f:
+            f.write(script_text)
+    except OSError as e:
+        error = 'error writing {}: {}'.format(temp_script_path, str(e))
+        raise GkeepException(error)
+
+    mv(temp_script_path, dest_path, sudo=True)
+    sudo_chown(dest_path, username, config.keeper_group)
+    chmod(dest_path, '750', sudo=True)
+
+
+def setup_git_shell_commands_directory(username: str, command_list: list):
     """
-    Create the git-shell-commands directory for a user and populate it with
-    symbolic links to allowed commands.
+    Create the git-shell-commands directory for a user, populate it with
+    symbolic links to allowed commands, and write custom scripts to allow
+    ssh-copy-id.
 
     The user will only be able to run the listed commands from an interactive
     shell.
@@ -135,6 +169,10 @@ def create_git_shell_commands(username: str, command_list: list):
         command_path = which(command)
         make_symbolic_link(command_path, git_shell_commands_path, sudo=True)
 
+    for script in ('exec', 'add_ssh_id', 'exit'):
+        dest_path = os.path.join(git_shell_commands_path, script)
+        write_git_shell_command_script(script, dest_path, username)
+
 
 def create_gitkeeper_directory(username):
     """
@@ -195,7 +233,7 @@ def create_user(username, user_type, first_name, last_name, email_address=None,
         create_faculty_dirs(username)
 
     if user_type == UserType.student:
-        create_git_shell_commands(username, ['passwd'])
+        setup_git_shell_commands_directory(username, ['passwd'])
 
     # email the credentials to the user if an email address was provided
     if email_address is not None:
@@ -215,7 +253,13 @@ def create_user(username, user_type, first_name, last_name, email_address=None,
 
         body += 'Enjoy!'
 
-        email_sender.enqueue(Email(email_address, subject, body))
+        if user_type == UserType.student:
+            email_priority = EmailPriority.LOW
+        else:
+            email_priority = EmailPriority.NORMAL
+
+        email_sender.enqueue(Email(email_address, subject, body,
+                                   priority=email_priority))
 
 
 def create_student_user(student: Student):
diff --git a/git-keeper-server/gkeepserver/data/add_ssh_id b/git-keeper-server/gkeepserver/data/add_ssh_id
new file mode 100755
index 00000000..45a3e45a
--- /dev/null
+++ b/git-keeper-server/gkeepserver/data/add_ssh_id
@@ -0,0 +1,12 @@
+#!/bin/sh
+# This script is used along with a dummy exec script to allow users to use
+# ssh-copy-id along with the git shell. This file should be placed in
+# ~/git-shell-commands/ along with exec.
+
+cd
+umask 077
+mkdir -p .ssh && { [ -z "'`tail -1c .ssh/authorized_keys 2>/dev/null`'" ] || echo >> .ssh/authorized_keys ; } && cat >> .ssh/authorized_keys || exit 1
+if type restorecon >/dev/null 2>&1
+then
+    restorecon -F .ssh .ssh/authorized_keys
+fi
diff --git a/git-keeper-server/gkeepserver/data/exec b/git-keeper-server/gkeepserver/data/exec
new file mode 100755
index 00000000..d37d1c50
--- /dev/null
+++ b/git-keeper-server/gkeepserver/data/exec
@@ -0,0 +1,15 @@
+#!/bin/bash
+# This is a dummy exec script that allows ssh-copy-id to work with the git
+# shell. It is to be placed in ~/git-shell-commands/ where the companion
+# add_ssh_id script must also exist.
+
+if [[ "$@" == *"authorized_keys"* ]]
+then
+    # exec is being used for a ssh-copy-id (likely) - run add_ssh_id
+    SCRIPT=$(readlink -f "$0")
+    DIR=$(readlink -f "$(dirname "$SCRIPT")")
+    $DIR/add_ssh_id
+else
+    # exec is being for something else - fail
+    exit 1
+fi
diff --git a/git-keeper-server/gkeepserver/data/exit b/git-keeper-server/gkeepserver/data/exit
new file mode 100644
index 00000000..7b570eed
--- /dev/null
+++ b/git-keeper-server/gkeepserver/data/exit
@@ -0,0 +1,5 @@
+#!/bin/bash
+# This is to be placed in ~/git-shell-commands so that ssh-copy-id has an exit
+# to call in the case where a key already exists.
+
+exit "$@"
diff --git a/git-keeper-server/gkeepserver/data/pre-receive b/git-keeper-server/gkeepserver/data/pre-receive
index d8f5c2fd..3f37c974 100644
--- a/git-keeper-server/gkeepserver/data/pre-receive
+++ b/git-keeper-server/gkeepserver/data/pre-receive
@@ -15,13 +15,15 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
-# This hook prevents students from pushing to a branch other than master.
+# This hook prevents students from pushing to a branch other than the one
+# existing branch.
 #
 # If this script exits with a 0 exit code the push is accepted, otherwise the
 # push is rejected.
 
-# we will only allow pushes to the master branch
-allowed_ref='refs/heads/master'
+# get the ref and name of the existing branch
+allowed_ref=$(git symbolic-ref HEAD)
+allowed_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
 
 # this hook receives push information about each ref that is pushed in the
 # form <old hash> <new hash> <ref name>
@@ -29,7 +31,7 @@ while read old_hash new_hash ref_name
 do
     if [ $ref_name != $allowed_ref ]
     then
-        echo "ERROR: You may only push to the master branch"
+        echo "ERROR: You may only push to the $allowed_branch branch"
         exit 1
     fi
 done
diff --git a/git-keeper-server/gkeepserver/email_sender_thread.py b/git-keeper-server/gkeepserver/email_sender_thread.py
index ea5e408e..7c13049d 100644
--- a/git-keeper-server/gkeepserver/email_sender_thread.py
+++ b/git-keeper-server/gkeepserver/email_sender_thread.py
@@ -41,7 +41,7 @@ def main():
 
 """
 
-from queue import Queue, Empty
+from queue import PriorityQueue, Empty
 from threading import Thread
 from time import time, sleep
 
@@ -78,7 +78,7 @@ def __init__(self):
 
         Thread.__init__(self)
 
-        self._email_queue = Queue()
+        self._email_queue = PriorityQueue()
 
         self._last_send_time = 0
 
@@ -125,8 +125,8 @@ def run(self):
                     email = self._email_queue.get(block=True, timeout=0.1)
 
                     if not isinstance(email, Email):
-                        warning = ('Item enqueued for emailing that is not an '
-                                   'email: {0}'.format(email))
+                        warning = ('Item dequeued for emailing that is '
+                                   'not an email: {0}'.format(email))
                         logger.log_warning(warning)
                     else:
                         self._send_email_with_rate_limiting(email)
diff --git a/git-keeper-server/gkeepserver/event_handlers/class_status_handler.py b/git-keeper-server/gkeepserver/event_handlers/class_status_handler.py
index 12ac5c00..fe2a9eb7 100644
--- a/git-keeper-server/gkeepserver/event_handlers/class_status_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/class_status_handler.py
@@ -44,11 +44,6 @@ def handle(self):
             raise HandlerException('Invalid status for CLASS_STATUS: {}'
                                    .format(self._status))
 
-        print('Handling class status:')
-        print(' Faculty:', self._faculty_username)
-        print(' Class:  ', self._class_name)
-        print(' Status: ', self._status)
-
         try:
             if self._status == 'open':
                 db.open_class(self._class_name, self._faculty_username)
diff --git a/git-keeper-server/gkeepserver/event_handlers/delete_handler.py b/git-keeper-server/gkeepserver/event_handlers/delete_handler.py
index 3b0d65ed..9008bc91 100755
--- a/git-keeper-server/gkeepserver/event_handlers/delete_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/delete_handler.py
@@ -51,12 +51,6 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling delete:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Assignment path:', assignment_path)
-
         try:
             if db.is_published(self._class_name, self._assignment_name,
                                self._faculty_username):
diff --git a/git-keeper-server/gkeepserver/event_handlers/disable_handler.py b/git-keeper-server/gkeepserver/event_handlers/disable_handler.py
index e6db65b0..08c9ee46 100755
--- a/git-keeper-server/gkeepserver/event_handlers/disable_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/disable_handler.py
@@ -51,12 +51,6 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling disable:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Assignment path:', assignment_path)
-
         try:
             if not db.is_published(self._class_name, self._assignment_name,
                                    self._faculty_username):
@@ -73,7 +67,8 @@ def handle(self):
                              .format(self._class_name, self._assignment_name))
             email_body = ('Assignment {} in class {} has been disabled. '
                           'No tests will be run if you push to your '
-                          'repository for this assignment.')
+                          'repository for this assignment.\n'
+                          .format(self._assignment_name, self._class_name))
             for student in db.get_class_students(self._class_name,
                                                  self._faculty_username):
                 email_sender.enqueue(Email(student.email_address,
diff --git a/git-keeper-server/gkeepserver/event_handlers/publish_handler.py b/git-keeper-server/gkeepserver/event_handlers/publish_handler.py
index 503db5ea..cd36fe05 100755
--- a/git-keeper-server/gkeepserver/event_handlers/publish_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/publish_handler.py
@@ -20,16 +20,13 @@
 """
 
 import os
-from tempfile import TemporaryDirectory
 
 from gkeepcore.csv_files import CSVError
-from gkeepcore.git_commands import git_init, git_push, git_add_all, git_commit
-from gkeepcore.local_csv_files import LocalCSVReader
-from gkeepcore.path_utils import faculty_assignment_dir_path, user_home_dir, \
-    class_student_csv_path, user_gitkeeper_path
-from gkeepcore.shell_command import CommandError
-from gkeepcore.student import students_from_csv, StudentError, Student
-from gkeepcore.system_commands import touch, sudo_chown, mkdir
+from gkeepcore.git_commands import git_add_all, git_commit
+from gkeepcore.path_utils import faculty_assignment_dir_path, \
+    user_gitkeeper_path
+from gkeepcore.student import StudentError, Student
+from gkeepcore.system_commands import touch, mkdir
 from gkeepserver.assignments import AssignmentDirectory, \
     AssignmentDirectoryError, setup_student_assignment, StudentAssignmentError
 from gkeepserver.database import db
@@ -37,7 +34,7 @@
 from gkeepserver.gkeepd_logger import gkeepd_logger
 from gkeepserver.handler_utils import log_gkeepd_to_faculty
 from gkeepserver.info_update_thread import info_updater
-from gkeepserver.server_configuration import config
+from gkeepserver.reports import reports_clone
 
 
 class PublishHandler(EventHandler):
@@ -58,19 +55,13 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling publish:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Assignment path:', assignment_path)
-
         try:
             if not db.class_is_open(self._class_name, self._faculty_username):
                 raise HandlerException(
                     '{} is not open'.format(self._class_name))
 
             assignment_dir = AssignmentDirectory(assignment_path)
-            self._ensure_not_published(assignment_dir)
+            self._ensure_not_published()
             students = self._setup_students_assignment_repos(assignment_dir)
             self._populate_reports_repo(assignment_dir, students)
 
@@ -94,7 +85,7 @@ def handle(self):
             warning = 'Publish failed: {0}'.format(e)
             gkeepd_logger.log_warning(warning)
 
-    def _ensure_not_published(self, assignment_dir: AssignmentDirectory):
+    def _ensure_not_published(self):
         # Throw an exception if the assignment is already published
         if db.is_published(self._class_name, self._assignment_name,
                            self._faculty_username):
@@ -102,27 +93,23 @@ def _ensure_not_published(self, assignment_dir: AssignmentDirectory):
 
     def _populate_reports_repo(self, assignment_dir: AssignmentDirectory,
                                students: list):
-        temp_dir = TemporaryDirectory()
-
-        temp_dir_path = temp_dir.name
-
-        git_init(temp_dir_path)
-
-        for student in students:
-            student_report_path = \
-                os.path.join(temp_dir_path, student.get_last_first_username())
-            mkdir(student_report_path)
-
-            placeholder_path = os.path.join(student_report_path,
-                                            '.placeholder')
-            touch(placeholder_path)
-
-        git_add_all(temp_dir_path)
-        git_commit(temp_dir_path, 'Initial commit')
-        git_push(temp_dir_path, dest=assignment_dir.reports_repo_path,
-                 sudo=True)
-        sudo_chown(assignment_dir.reports_repo_path, self._faculty_username,
-                   config.keeper_group, recursive=True)
+        with reports_clone(assignment_dir) as temp_dir_path:
+            reports_placeholder_path = os.path.join(temp_dir_path,
+                                                    '.placeholder')
+            touch(reports_placeholder_path)
+
+            for student in students:
+                student_report_path = \
+                    os.path.join(temp_dir_path,
+                                 student.get_last_first_username())
+                mkdir(student_report_path)
+
+                student_placeholder_path = os.path.join(student_report_path,
+                                                        '.placeholder')
+                touch(student_placeholder_path)
+
+            git_add_all(temp_dir_path)
+            git_commit(temp_dir_path, 'Initial commit')
 
     def _setup_students_assignment_repos(self,
                                          assignment_dir: AssignmentDirectory):
diff --git a/git-keeper-server/gkeepserver/event_handlers/students_add_handler.py b/git-keeper-server/gkeepserver/event_handlers/students_add_handler.py
index f3890250..a5cb6e73 100755
--- a/git-keeper-server/gkeepserver/event_handlers/students_add_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/students_add_handler.py
@@ -168,7 +168,7 @@ def _activate_existing_student(self, student: Student,
                                    class_dir_path: str):
         try:
             db.activate_student_in_class(self._class_name,
-                                         student.username,
+                                         student.email_address,
                                          self._faculty_username)
         except DatabaseException as e:
             warning = ('Error activating student {} in the database: '
diff --git a/git-keeper-server/gkeepserver/event_handlers/submission_handler.py b/git-keeper-server/gkeepserver/event_handlers/submission_handler.py
index 0b8bb53c..47b20fff 100644
--- a/git-keeper-server/gkeepserver/event_handlers/submission_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/submission_handler.py
@@ -38,15 +38,6 @@ class SubmissionHandler(EventHandler):
     def handle(self):
         """Take action after a student pushes a new submission."""
 
-        print('Handling submission:')
-        print(' Student:    ', self._student_username)
-        print(' Faculty:    ', self._faculty_username)
-        print(' Class:      ', self._class_name)
-        print(' Assignment: ', self._assignment_name)
-        print(' Repo path:  ', self._submission_repo_path)
-        print(' Commit hash:', self._commit_hash)
-        print()
-
         # We need to create a submission object to put in the
         # new_submission_queue.  This requires a Student object and
         # paths to student repo, tests (not a repo), and reports repo.
diff --git a/git-keeper-server/gkeepserver/event_handlers/trigger_handler.py b/git-keeper-server/gkeepserver/event_handlers/trigger_handler.py
index ced75ea3..4c18e462 100755
--- a/git-keeper-server/gkeepserver/event_handlers/trigger_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/trigger_handler.py
@@ -47,12 +47,6 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling trigger:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Students:       ', self._student_usernames)
-
         try:
             assignment_dir = AssignmentDirectory(assignment_path)
 
@@ -116,7 +110,8 @@ def _trigger_tests(self, students, assignment_dir: AssignmentDirectory):
                                              self._class_name,
                                              self._assignment_name, home_dir)
 
-            commit_hash = git_head_hash(submission_repo_path)
+            commit_hash = git_head_hash(submission_repo_path,
+                                        user=student.username)
 
             submission = Submission(student, submission_repo_path, commit_hash,
                                     assignment_dir, self._faculty_username,
diff --git a/git-keeper-server/gkeepserver/event_handlers/update_handler.py b/git-keeper-server/gkeepserver/event_handlers/update_handler.py
index c47f73ec..b0bbf94a 100644
--- a/git-keeper-server/gkeepserver/event_handlers/update_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/update_handler.py
@@ -30,7 +30,7 @@
 from gkeepserver.assignments import AssignmentDirectory, \
     create_base_code_repo, copy_email_txt_file, \
     copy_tests_dir, remove_student_assignment, setup_student_assignment, \
-    StudentAssignmentError, write_run_action_sh
+    StudentAssignmentError
 from gkeepserver.database import db
 from gkeepserver.event_handler import EventHandler, HandlerException
 from gkeepserver.gkeepd_logger import gkeepd_logger
@@ -58,13 +58,6 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling update:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Path:           ', self._upload_path)
-        print(' Assignment path:', assignment_path)
-
         if db.is_disabled(self._class_name, self._assignment_name,
                           self._faculty_username):
             error = ('Assignment {} in class {} is disabled and '
@@ -144,8 +137,6 @@ def _update_items(self, assignment_dir: AssignmentDirectory,
             rm(assignment_dir.tests_path, recursive=True)
             copy_tests_dir(assignment_dir, upload_dir.tests_path)
 
-            write_run_action_sh(assignment_dir, upload_dir)
-
         # sanity check
         assignment_dir.check()
 
diff --git a/git-keeper-server/gkeepserver/event_handlers/upload_handler.py b/git-keeper-server/gkeepserver/event_handlers/upload_handler.py
index f0f1ce81..2a753364 100644
--- a/git-keeper-server/gkeepserver/event_handlers/upload_handler.py
+++ b/git-keeper-server/gkeepserver/event_handlers/upload_handler.py
@@ -32,8 +32,7 @@
 from gkeepcore.valid_names import validate_assignment_name
 from gkeepserver.assignments import AssignmentDirectory, \
     AssignmentDirectoryError, create_base_code_repo, copy_email_txt_file, \
-    copy_tests_dir, setup_student_assignment, StudentAssignmentError, \
-    write_run_action_sh
+    copy_tests_dir, setup_student_assignment, StudentAssignmentError
 from gkeepserver.database import db
 from gkeepserver.event_handler import EventHandler, HandlerException
 from gkeepserver.gkeepd_logger import gkeepd_logger
@@ -61,13 +60,6 @@ def handle(self):
                                                       self._assignment_name,
                                                       gitkeeper_path)
 
-        print('Handling upload:')
-        print(' Faculty:        ', self._faculty_username)
-        print(' Class:          ', self._class_name)
-        print(' Assignment:     ', self._assignment_name)
-        print(' Path:           ', self._upload_path)
-        print(' Assignment path:', assignment_path)
-
         assignment_dir = AssignmentDirectory(assignment_path, check=False)
 
         try:
@@ -167,8 +159,6 @@ def _setup_assignment_dir(self, assignment_dir: AssignmentDirectory):
             error = ('error copying assignment files: {0}'.format(str(e)))
             raise HandlerException(error)
 
-        write_run_action_sh(assignment_dir, upload_dir)
-
         # set permissions on assignment directory
         try:
             chmod(assignment_dir.path, '750', sudo=True)
diff --git a/git-keeper-server/gkeepserver/gkeepd.py b/git-keeper-server/gkeepserver/gkeepd.py
index 52f038b2..61d4c234 100644
--- a/git-keeper-server/gkeepserver/gkeepd.py
+++ b/git-keeper-server/gkeepserver/gkeepd.py
@@ -29,9 +29,9 @@
 import argparse
 import fcntl
 import sys
+import traceback
 from queue import Queue, Empty
 from signal import signal, SIGINT, SIGTERM
-from traceback import extract_tb
 
 from gkeepcore.gkeep_exception import GkeepException
 from gkeepcore.version import __version__ as core_version
@@ -176,13 +176,15 @@ def main():
         except (GkeepException, Exception) as e:
             # A handler's handle() method should catch all exceptions. If we
             # get here there is likely an issue with the handler.
-            error = ('An exception was caught that should have been caught\n'
-                     'earlier. This is likely due to a bug in the code.\n'
-                     'Please report this to the git-keeper developers along\n'
+            error = ('**ERROR: UNEXPECTED EXCEPTION**\n'
+                     'This is likely due to a bug.\n'
+                     'Please report this to the git-keeper developers along '
                      'with the following stack trace:\n{0}'
-                     .format(extract_tb(e)))
-            print(error, file=sys.stderr)
-            logger.log_error('Unexpected exception. Please report this bug. '
+                     .format(traceback.format_exc()))
+            print(error)
+            logger.log_error('Unexpected exception. Please report this bug '
+                             'along with the stack trace from gkeepd\'s '
+                             'standard output if possible. '
                              '{0}: {1}'.format(type(e), e))
 
     print('Shutting down. Waiting for threads to finish ... ', end='')
diff --git a/git-keeper-server/gkeepserver/gkeepd_logger.py b/git-keeper-server/gkeepserver/gkeepd_logger.py
index 04f58112..b81a2c83 100644
--- a/git-keeper-server/gkeepserver/gkeepd_logger.py
+++ b/git-keeper-server/gkeepserver/gkeepd_logger.py
@@ -168,6 +168,7 @@ def _cleanup_and_log(self, log_level, text: str):
             # Replace newlines in text with spaces so the entire log text is on
             # one line
             text = text.replace('\n', ' ')
+            print('{}: {}'.format(log_level.name, text), flush=True)
             command = log_append_command(self._log_file_path,
                                          log_level.name, text,
                                          human_readable=True)
diff --git a/git-keeper-server/gkeepserver/info_update_thread.py b/git-keeper-server/gkeepserver/info_update_thread.py
index e3ef0bc7..23c04090 100644
--- a/git-keeper-server/gkeepserver/info_update_thread.py
+++ b/git-keeper-server/gkeepserver/info_update_thread.py
@@ -266,7 +266,6 @@ def _update_info(self, payload: InfoUpdatePayload):
 
         try:
             if payload.instruction == InfoInstruction.FULL_SCAN:
-                logger.log_info(str(type(payload)))
                 self._full_scan(payload.faculty_username)
 
             elif payload.instruction == InfoInstruction.CLASS_SCAN:
@@ -436,7 +435,8 @@ def _assignment_scan(self, faculty_username,
             return
 
         reports_repo_path = assignment_dir.reports_repo_path
-        reports_repo_hash = git_head_hash(assignment_dir.reports_repo_path)
+        reports_repo_hash = git_head_hash(assignment_dir.reports_repo_path,
+                                          user=faculty_username)
 
         reports_repo_info = {
             'path': reports_repo_path,
@@ -458,7 +458,8 @@ def _assignment_scan(self, faculty_username,
                                              assignment_name, student_home_dir)
 
             try:
-                hashes_and_times = git_hashes_and_times(assignment_repo_path)
+                hashes_and_times = git_hashes_and_times(assignment_repo_path,
+                                                        user=student.username)
             except GkeepException as e:
                 warning = ('Could not get hashes for {0}: {1}'
                            .format(assignment_repo_path, e))
diff --git a/git-keeper-server/gkeepserver/reports.py b/git-keeper-server/gkeepserver/reports.py
new file mode 100644
index 00000000..6ee7794f
--- /dev/null
+++ b/git-keeper-server/gkeepserver/reports.py
@@ -0,0 +1,57 @@
+# Copyright 2022 Nathan Sommer and Ben Coleman
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import contextlib
+import os
+from tempfile import TemporaryDirectory
+from time import time
+
+from gkeepcore.git_commands import git_clone, git_push
+from gkeepcore.system_commands import sudo_chown, mv, rm
+from gkeepserver.assignments import AssignmentDirectory
+from gkeepserver.server_configuration import config
+
+
+@contextlib.contextmanager
+def reports_clone(assignment_dir: AssignmentDirectory):
+    """
+    Provides a context manager to use when making changes to a reports
+    repository. A clone of the repository is made in a temporary directory,
+    and the path to the clone is yielded to the caller. The caller is
+    responsible for adding and committing changes within this clone. Afterwords
+    the clone is moved into the assignment directory, ownership is changed to
+    be the faculty user, the changes in the clone are pushed to the bare
+    reports repo, and then the clone is deleted.
+
+    :param assignment_dir: AssignmentDirectory object associated with the
+     assignment directory that contains the reports repository
+    """
+
+    with TemporaryDirectory() as temp_path:
+        temp_reports_clone_path = os.path.join(temp_path, 'reports')
+        git_clone(assignment_dir.reports_repo_path, temp_path)
+
+        yield temp_reports_clone_path
+
+        reports_clone_path = os.path.join(assignment_dir.path,
+                                          'reports_clone_{}'.format(time()))
+        mv(temp_reports_clone_path, reports_clone_path, sudo=True)
+
+    sudo_chown(reports_clone_path, assignment_dir.faculty_username,
+               config.keeper_group, recursive=True)
+    git_push(reports_clone_path, sudo=True,
+             user=assignment_dir.faculty_username)
+    rm(reports_clone_path, recursive=True, sudo=True)
diff --git a/git-keeper-server/gkeepserver/server_configuration.py b/git-keeper-server/gkeepserver/server_configuration.py
index 52821f30..68a75d45 100644
--- a/git-keeper-server/gkeepserver/server_configuration.py
+++ b/git-keeper-server/gkeepserver/server_configuration.py
@@ -18,7 +18,7 @@
 This module allows the parsing of and provides access to the the server
 configuration file.
 
-The default configuration file localtion is ~/server.cfg. This can be
+The default configuration file location is ~/server.cfg. This can be
 customized by passing a path to parser().
 
 The configuration file must be in the INI format:
@@ -202,6 +202,7 @@ def _initialize_default_attributes(self):
         self.email_username = None
         self.email_password = None
         self.email_interval = 2
+        self.use_html = False
 
         # admin
         self.admin_email = None
@@ -251,6 +252,7 @@ def _set_email_options(self):
             'email_username',
             'email_password',
             'email_interval',
+            'use_html',
         ]
 
         for name in optional_options:
@@ -261,15 +263,16 @@ def _set_email_options(self):
                 value = self._parser.get('email', name)
                 setattr(self, name, value)
 
-        # use_tls must be true or false
-        if isinstance(self.use_tls, str):
-            if self.use_tls.lower() == 'true':
-                self.use_tls = True
-            elif self.use_tls.lower() == 'false':
-                self.use_tls = False
-            else:
-                error = 'use_tls must be true or false'
-                raise ServerConfigurationError(error)
+        # use_tls and use_html must be true or false
+        for attr in ('use_tls', 'use_html'):
+            if isinstance(getattr(self, attr), str):
+                if getattr(self, attr).lower() == 'true':
+                    setattr(self, attr, True)
+                elif getattr(self, attr).lower() == 'false':
+                    setattr(self, attr, False)
+                else:
+                    error = '{} must be true or false'.format(attr)
+                    raise ServerConfigurationError(error)
 
         # email_interval must be a non-negative number
         if isinstance(self.email_interval, str):
@@ -338,11 +341,6 @@ def _set_gkeepd_options(self):
             'test_thread_count',
             'tests_timeout',
             'tests_memory_limit',
-            'keeper_user',
-            'keeper_group',
-            'tester_user',
-            'faculty_group',
-            'student_group'
         ]
 
         for name in optional_options:
diff --git a/git-keeper-server/gkeepserver/server_email.py b/git-keeper-server/gkeepserver/server_email.py
index 5c9ab165..7975fecd 100644
--- a/git-keeper-server/gkeepserver/server_email.py
+++ b/git-keeper-server/gkeepserver/server_email.py
@@ -23,12 +23,13 @@
 EmailSenderThread which provides rate limiting.
 
 """
-
+import html
 import os
 from email.header import Header
 from email.mime.application import MIMEApplication
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
+from enum import IntEnum
 from smtplib import SMTP
 
 from gkeepcore.gkeep_exception import GkeepException
@@ -40,13 +41,24 @@ class EmailException(GkeepException):
     pass
 
 
+class EmailPriority(IntEnum):
+    """
+    Enum used to specify an email's priority level for use by the priority
+    queue in the email sending thread.
+    """
+    LOW = 2
+    NORMAL = 1
+    HIGH = 0
+
+
 class Email:
     """
     Builds an email that can be sent using smtplib and provides a method
     to send the email.
     """
     def __init__(self, to_address, subject, body, files_to_attach=None,
-                 max_character_count=1000000):
+                 max_character_count=1000000, priority=EmailPriority.NORMAL,
+                 html_pre_body=False):
         """
         Construct an email object.
 
@@ -60,6 +72,11 @@ def __init__(self, to_address, subject, body, files_to_attach=None,
         :param files_to_attach: a list of file paths to attach to the email
         :param max_character_count: if the email is longer than this number of
          characters it will be truncated
+        :param priority: an EmailPriority representing the email's priority
+         in the send queue
+        :param html_pre_body: if True, the body of the email will be sent both
+         as plain text and HTML, and for the latter the code will be escaped
+         and wrapped in <pre></pre> tags
         """
 
         self._send_attempts = 0
@@ -71,6 +88,8 @@ def __init__(self, to_address, subject, body, files_to_attach=None,
         self._subject = subject
         self._files_to_attach = files_to_attach
 
+        self.priority = priority
+
         # regardless of how the body is passed in, represent it by a list of
         # lines that have trailing whitespace removed
         if isinstance(body, list):
@@ -99,14 +118,18 @@ def __init__(self, to_address, subject, body, files_to_attach=None,
                            'long length. If important information seems '
                            'missing, contact your instructor.\r\n')
 
-        self._build_mime_message(files_to_attach)
+        self._build_mime_message(files_to_attach, html_pre_body)
 
     def __repr__(self):
         repr_string = 'To: {0}, Subject: {1}'.format(self.to_address,
                                                      self._subject)
         return repr_string
 
-    def _build_mime_message(self, files_to_attach):
+    def __lt__(self, other):
+        # Provide < comparison for use by the PriorityQueue
+        return self.priority < other.priority
+
+    def _build_mime_message(self, files_to_attach, html_pre_body):
         # Create the final message by building up a MIMEMultipart object and
         # then storing the final message as a string in _message_string
 
@@ -116,7 +139,10 @@ def _build_mime_message(self, files_to_attach):
         subject_header = Header('{0}'.format(self._subject), 'utf-8')
         reply_to_header = Header('{0}'.format(config.from_address), 'utf-8')
 
-        message = MIMEMultipart()
+        if html_pre_body and config.use_html:
+            message = MIMEMultipart('alternative')
+        else:
+            message = MIMEMultipart()
 
         # put the headers in the message
         message['Subject'] = subject_header
@@ -125,7 +151,12 @@ def _build_mime_message(self, files_to_attach):
         message['reply-to'] = reply_to_header
 
         # attach the body
-        message.attach(MIMEText(self._body, _charset='utf-8'))
+        if html_pre_body and config.use_html:
+            html_body = '<pre>{}</pre>\r\n'.format(html.escape(self._body))
+            message.attach(MIMEText(self._body, 'plain', _charset='utf-8'))
+            message.attach(MIMEText(html_body, 'html', _charset='utf-8'))
+        else:
+            message.attach(MIMEText(self._body, _charset='utf-8'))
 
         # attach any files
         for file_path in files_to_attach or []:
diff --git a/git-keeper-server/gkeepserver/submission.py b/git-keeper-server/gkeepserver/submission.py
index 88e9b6d0..e15ccd5f 100644
--- a/git-keeper-server/gkeepserver/submission.py
+++ b/git-keeper-server/gkeepserver/submission.py
@@ -23,15 +23,18 @@
 from time import strftime, time
 from tempfile import TemporaryDirectory, mkdtemp
 
+from gkeepcore.action_scripts import get_action_script_and_interpreter
+from gkeepcore.gkeep_exception import GkeepException
 from gkeepcore.student import Student
 from gkeepserver.assignments import AssignmentDirectory
 from gkeepserver.database import db
 from gkeepserver.gkeepd_logger import gkeepd_logger as logger
 from gkeepcore.git_commands import git_clone, git_add_all, git_commit, \
-    git_push, git_checkout
-from gkeepcore.system_commands import cp, sudo_chown, rm, chmod
+    git_checkout
+from gkeepcore.system_commands import cp, sudo_chown, rm, chmod, mv
 from gkeepcore.shell_command import run_command_in_directory
 from gkeepserver.email_sender_thread import email_sender
+from gkeepserver.reports import reports_clone
 from gkeepserver.server_configuration import config
 from gkeepserver.server_email import Email
 from gkeepcore.path_utils import parse_submission_repo_path, user_home_dir
@@ -54,11 +57,11 @@ def __init__(self, student: Student, student_repo_path, commit_hash,
          assignment
         """
 
+        self.assignment_dir = assignment_dir
         self.student = student
         self.student_repo_path = student_repo_path
         self.commit_hash = commit_hash
         self.tests_path = assignment_dir.tests_path
-        self.run_action_sh_path = assignment_dir.run_action_sh_path
         self.reports_repo_path = assignment_dir.reports_repo_path
         self.faculty_username = faculty_username
         self.faculty_email = faculty_email
@@ -128,7 +131,7 @@ def run_tests(self):
         temp_tests_path = os.path.join(temp_path, 'tests')
 
         temp_run_action_sh_path = os.path.join(temp_path, 'run_action.sh')
-        cp(self.run_action_sh_path, temp_run_action_sh_path)
+        write_run_action_sh(temp_run_action_sh_path, self.tests_path)
 
         faculty_username, class_name, assignment_name = \
             parse_submission_repo_path(self.student_repo_path)
@@ -141,7 +144,7 @@ def run_tests(self):
 
         # execute action.sh and capture the output
         try:
-            cmd = ['sudo', '-u', config.tester_user, 'bash',
+            cmd = ['sudo', '--user', config.tester_user, '--set-home', 'bash',
                    temp_run_action_sh_path, temp_assignment_path,
                    self.student.username, self.student.email_address,
                    self.student.last_name, self.student.first_name]
@@ -151,47 +154,39 @@ def run_tests(self):
             subject = ('[{0}] {1} submission test results'
                        .format(class_name, assignment_name))
             email_sender.enqueue(Email(self.student.email_address, subject,
-                                       body))
+                                       body, html_pre_body=True))
 
             if self.student.username != faculty_username:
                 # add the report to the reports repository
 
-                reports_temp = TemporaryDirectory()
-
-                # put the report file into the reports repo
-                git_clone(self.reports_repo_path, reports_temp.name)
-
-                temp_reports_repo_path = os.path.join(reports_temp.name,
-                                                      'reports')
-
-                first_last_username = self.student.get_last_first_username()
-
-                student_report_dir_path = os.path.join(temp_reports_repo_path,
-                                                       first_last_username)
-                os.makedirs(student_report_dir_path, exist_ok=True)
+                with reports_clone(self.assignment_dir) as temp_reports_repo_path:
+                    last_first_username = self.student.get_last_first_username()
 
-                timestamp = strftime('%Y-%m-%d-%H:%M:%S-%Z')
-                report_filename = 'report-{0}.txt'.format(timestamp)
-                report_file_path = os.path.join(student_report_dir_path,
-                                                report_filename)
+                    student_report_dir_path = os.path.join(temp_reports_repo_path,
+                                                           last_first_username)
+                    os.makedirs(student_report_dir_path, exist_ok=True)
 
-                counter = 1
-                while os.path.exists(report_file_path):
-                    report_filename = 'report-{0}-{1}.txt'.format(timestamp,
-                                                                  counter)
+                    timestamp = strftime('%Y-%m-%d-%H:%M:%S-%Z')
+                    report_filename = 'report-{0}.txt'.format(timestamp)
                     report_file_path = os.path.join(student_report_dir_path,
                                                     report_filename)
-                    counter += 1
 
-                with open(report_file_path, 'w') as f:
-                    f.write(body)
+                    counter = 1
+                    while os.path.exists(report_file_path):
+                        report_filename = 'report-{0}-{1}.txt'.format(timestamp,
+                                                                      counter)
+                        report_file_path = os.path.join(student_report_dir_path,
+                                                        report_filename)
+                        counter += 1
 
-                git_add_all(temp_reports_repo_path)
-                git_commit(temp_reports_repo_path, 'report submission')
-                git_push(temp_reports_repo_path, dest='origin', sudo=True)
+                    with open(report_file_path, 'w') as f:
+                        f.write(body)
 
-                sudo_chown(self.reports_repo_path, faculty_username,
-                           config.keeper_group, recursive=True)
+                    reports_commit_message = ('Submission report for {}'
+                                              .format(last_first_username))
+
+                    git_add_all(temp_reports_repo_path)
+                    git_commit(temp_reports_repo_path, reports_commit_message)
         except Exception as e:
             report_failure(assignment_name, self.student,
                            self.faculty_email, str(e))
@@ -203,6 +198,49 @@ def run_tests(self):
                          .format(self.student_repo_path))
 
 
+def write_run_action_sh(dest_path: str, tests_path: str):
+    """
+    Write run_action.sh before testing.
+
+    The contents of the script depend on the type of action script used in the
+    uploaded assignment.
+
+    :param dest_path: final path of run_action.sh
+    :param tests_path: path to a directory containing the tests
+    """
+    temp_dir = TemporaryDirectory()
+    temp_dir_path = temp_dir.name
+
+    temp_run_action_sh_path = os.path.join(temp_dir_path, 'run_action.sh')
+
+    template = '''#!/bin/bash
+GLOBAL_TIMEOUT={global_timeout}
+GLOBAL_MEM_LIMIT_MB={global_memory_limit}
+GLOBAL_MEM_LIMIT_KB=$(($GLOBAL_MEM_LIMIT_MB * 1024))
+ulimit -v $GLOBAL_MEM_LIMIT_KB
+trap 'kill -INT -$pid' INT
+timeout $GLOBAL_TIMEOUT {interpreter} {script_name} "$@" &
+pid=$!
+wait $pid
+'''
+
+    script_name, interpreter = get_action_script_and_interpreter(tests_path)
+
+    if script_name is None or interpreter is None:
+        raise GkeepException('No valid action script found')
+
+    run_action_sh_contents = \
+        template.format(global_timeout=config.tests_timeout,
+                        global_memory_limit=config.tests_memory_limit,
+                        interpreter=interpreter,
+                        script_name=script_name)
+
+    with open(temp_run_action_sh_path, 'w') as f:
+        f.write(run_action_sh_contents)
+
+    mv(temp_run_action_sh_path, dest_path, sudo=True)
+
+
 def report_failure(assignment, student, faculty_email, message):
 
     s_subject = ('{0}: Failed to process submission - contact instructor'
diff --git a/git-keeper-server/gkeepserver/version.py b/git-keeper-server/gkeepserver/version.py
index cbc028cc..da6aa3b7 100644
--- a/git-keeper-server/gkeepserver/version.py
+++ b/git-keeper-server/gkeepserver/version.py
@@ -2,4 +2,4 @@
 # Instead, edit the VERSION file in the top level directory of the git-keeper
 # project and then run bump_version.py.
 
-__version__ = '0.3.0'
+__version__ = '0.3.1'
diff --git a/requirements.txt b/requirements.txt
index 52e8d3bf..6dd62d9e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 argcomplete==1.9.3
-paramiko==2.7.1
+paramiko==2.10.1
 pytest==5.3.5
 python-vagrant==0.5.15
 robotframework==3.0.3


Test patch:
diff --git a/git-keeper-client/gkeepclient/test_solution.py b/git-keeper-client/gkeepclient/test_solution.py
index 392bdf3b..a2d278ce 100644
--- a/git-keeper-client/gkeepclient/test_solution.py
+++ b/git-keeper-client/gkeepclient/test_solution.py
@@ -24,9 +24,10 @@
 from gkeepclient.client_configuration import config
 from gkeepclient.client_function_decorators import config_parsed, \
     server_interface_connected, class_exists, assignment_exists
+from gkeepclient.server_actions import trigger_tests
 from gkeepclient.server_interface import server_interface
 from gkeepcore.git_commands import git_clone_remote, git_add_all, git_commit, \
-    git_push
+    git_push, git_unstaged_changes_exist
 from gkeepcore.gkeep_exception import GkeepException
 from gkeepcore.system_commands import cp, rm
 
@@ -53,7 +54,7 @@ def test_solution(class_name: str, assignment_name: str, solution_path: str):
         error = '{} is not a directory'.format(solution_path)
         raise GkeepException(error)
 
-    print('Pushing {} for assignment {} in class {}'
+    print('Testing solution {} for assignment {} in class {}'
           .format(solution_path, assignment_name, class_name))
 
     solution_base_name = os.path.basename(os.path.normpath(solution_path))
@@ -83,8 +84,15 @@ def test_solution(class_name: str, assignment_name: str, solution_path: str):
     cp(os.path.join(repo_temp_path, '.git'), solution_temp_path,
        recursive=True)
 
-    git_add_all(solution_temp_path)
-    git_commit(solution_temp_path, 'Testing')
-    git_push(solution_temp_path, force=True)
-
-    print('Solution pushed successfully')
+    if git_unstaged_changes_exist(solution_temp_path):
+        git_add_all(solution_temp_path)
+        git_commit(solution_temp_path, 'Testing')
+        print('Pushing your solution using git')
+        git_push(solution_temp_path, force=True)
+        print('Solution pushed successfully, you should receive a report '
+              'via email')
+    else:
+        print('Solution is up to date with remote, using gkeep trigger to '
+              'trigger tests')
+        trigger_tests(class_name, assignment_name, [server_username])
+        print('You should receive a report via email')
diff --git a/tests/acceptance/assignments/good_simple2/correct_solution/code.py b/tests/acceptance/assignments/good_simple2/correct_solution/code.py
new file mode 100644
index 00000000..2ea70615
--- /dev/null
+++ b/tests/acceptance/assignments/good_simple2/correct_solution/code.py
@@ -0,0 +1,3 @@
+
+def go():
+    return
diff --git a/tests/acceptance/assignments/run_py/base_code/code.py b/tests/acceptance/assignments/run_py/base_code/code.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/acceptance/assignments/run_py/email.txt b/tests/acceptance/assignments/run_py/email.txt
new file mode 100644
index 00000000..ec82aef1
--- /dev/null
+++ b/tests/acceptance/assignments/run_py/email.txt
@@ -0,0 +1,1 @@
+run_py assignment
diff --git a/tests/acceptance/assignments/run_py/infinite_loop_submission/code.py b/tests/acceptance/assignments/run_py/infinite_loop_submission/code.py
new file mode 100644
index 00000000..db8e9558
--- /dev/null
+++ b/tests/acceptance/assignments/run_py/infinite_loop_submission/code.py
@@ -0,0 +1,4 @@
+from time import sleep
+
+while True:
+    sleep(1)
diff --git a/tests/acceptance/assignments/run_py/tests/action.sh b/tests/acceptance/assignments/run_py/tests/action.sh
new file mode 100644
index 00000000..7a434336
--- /dev/null
+++ b/tests/acceptance/assignments/run_py/tests/action.sh
@@ -0,0 +1,3 @@
+python3 "$1/code.py"
+
+exit 0
diff --git a/tests/acceptance/complex_paths.robot b/tests/acceptance/complex_paths.robot
new file mode 100644
index 00000000..370d944c
--- /dev/null
+++ b/tests/acceptance/complex_paths.robot
@@ -0,0 +1,74 @@
+# Copyright 2021 Nathan Sommer and Ben Coleman
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+*** Settings ***
+Resource    resources/setup.robot
+
+
+*** Test Cases ***
+Adding And Removing Students
+    [Tags]    happy_path
+    # start with an empty class, upload and publish good_simple
+    Launch Gkeepd And Configure Admin Account on Client
+    Add Faculty and Configure Accounts on Client    faculty1
+    Make Empty File    faculty1    cs1.csv
+    Gkeep Add Succeeds    faculty=faculty1    class_name=cs1
+    Add Assignment to Client    faculty1    good_simple
+    Gkeep Upload Succeeds    faculty1    cs1    good_simple
+    New Assignment Email Exists    faculty1    cs1    good_simple
+    Gkeep Publish Succeeds    faculty1    cs1    good_simple
+    # add student1, who should then get good_simple
+    Add To Class CSV    faculty=faculty1    class_name=cs1    username=student1
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Create Accounts On Client    student1
+    Create Git Config    student1
+    New Assignment Email Exists    student1    cs1    good_simple
+    Clone Assignment    student1    faculty1    cs1    good_simple
+    Student Submits    student1    faculty1    cs1    good_simple    correct_solution
+    Submission Test Results Email Exists    student1    cs1    good_simple    Done
+    # add student2, who should also get good_simple
+    Add To Class CSV    faculty=faculty1    class_name=cs1    username=student2
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Create Accounts On Client    student2
+    Create Git Config    student2
+    New Assignment Email Exists    student2    cs1    good_simple
+    Clone Assignment    student2    faculty1    cs1    good_simple
+    Student Submits    student2    faculty1    cs1    good_simple    correct_solution
+    Submission Test Results Email Exists    student2    cs1    good_simple    Done
+    # remove student1
+    Remove From Class    faculty=faculty1    class_name=cs1    student=student1
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Class Does Not Contain Student    faculty1    cs1    student1
+    # add and publish good_simple2, which student2 should get but student1
+    # should not
+    Add Assignment to Client    faculty1    good_simple2
+    Gkeep Upload Succeeds    faculty1    cs1    good_simple2
+    New Assignment Email Exists    faculty1    cs1    good_simple2
+    Gkeep Publish Succeeds    faculty1    cs1    good_simple2
+    New Assignment Email Exists    student2    cs1    good_simple2
+    New Assignment Email Does Not Exist    student1    cs1    good_simple_2
+    Clone Assignment    student2    faculty1    cs1    good_simple2
+    Student Submits    student2    faculty1    cs1    good_simple2    correct_solution
+    Submission Test Results Email Exists    student2    cs1    good_simple2    Done
+    # add student1 back in, who should get good_simple2
+    Add To Class CSV    faculty=faculty1    class_name=cs1    username=student1
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Class Contains Student    faculty1    cs1    student1
+    Class Contains Student    faculty1    cs1    student2
+    New Assignment Email Exists    student1    cs1    good_simple2
+    Clone Assignment    student1    faculty1    cs1    good_simple2
+    Student Submits    student1    faculty1    cs1    good_simple2    correct_solution
+    Submission Test Results Email Exists    student1    cs1    good_simple2    Done
diff --git a/tests/acceptance/files/valid_server.cfg b/tests/acceptance/files/valid_server.cfg
index 32622d52..465f1e69 100644
--- a/tests/acceptance/files/valid_server.cfg
+++ b/tests/acceptance/files/valid_server.cfg
@@ -16,5 +16,5 @@ admin_last_name = Admin
 
 [gkeepd]
 test_thread_count = 1
-tests_timeout = 20
+tests_timeout = 2
 tests_memory_limit = 512
diff --git a/tests/acceptance/gkclient_base/Vagrantfile b/tests/acceptance/gkclient_base/Vagrantfile
index 49d188f4..5bb80ffe 100644
--- a/tests/acceptance/gkclient_base/Vagrantfile
+++ b/tests/acceptance/gkclient_base/Vagrantfile
@@ -15,7 +15,7 @@
 
 
 Vagrant.configure("2") do |config|
-    config.vm.box = "ubuntu/xenial64"
+    config.vm.box = "ubuntu/bionic64"
     config.vm.provision "shell", inline:<<-SCRIPT
         useradd -ms /bin/bash keeper
         usermod -aG keeper keeper
diff --git a/tests/acceptance/gkeep_fetch.robot b/tests/acceptance/gkeep_fetch.robot
index 661d0cbc..aa350d10 100644
--- a/tests/acceptance/gkeep_fetch.robot
+++ b/tests/acceptance/gkeep_fetch.robot
@@ -32,7 +32,7 @@ Setup Server and Client Accounts
     Gkeep Upload Succeeds   faculty1   cs1    good_simple
     Gkeep Publish Succeeds  faculty1    cs1     good_simple
     Clone Assignment  student1  faculty1    cs1     good_simple
-    Student Submits Correct Solution    student1    faculty1    cs1    good_simple
+    Student Submits    student1    faculty1    cs1    good_simple    correct_solution
     Submission Test Results Email Exists    student1    cs1    good_simple    Done
 
 
@@ -51,7 +51,7 @@ Second Fetch Updates Submissions
     Verify Submission Count   faculty1    fetched_assignments    cs1    good_simple    student2    0
     # Student 2 submits and then re-fetch
     Clone Assignment  student2  faculty1    cs1     good_simple
-    Student Submits Correct Solution    student2    faculty1    cs1    good_simple
+    Student Submits    student2    faculty1    cs1    good_simple    correct_solution
     Submission Test Results Email Exists    student2    cs1    good_simple    Done
     Fetch Assignment    faculty1    cs1    good_simple    fetched_assignments
     Verify Submission Count   faculty1    fetched_assignments    cs1    good_simple    student1    1
diff --git a/tests/acceptance/gkeep_modify.robot b/tests/acceptance/gkeep_modify.robot
index 9850a345..bd62835a 100644
--- a/tests/acceptance/gkeep_modify.robot
+++ b/tests/acceptance/gkeep_modify.robot
@@ -51,4 +51,11 @@ Malformed CSV
     Add File To Client    faculty1    files/malformed_cs1.csv    cs1.csv
     Gkeep Modify Fails    faculty=faculty1    class_name=cs1
 
-
+Remove Then Add Student
+    [Tags]    happy_path
+    Remove From Class    faculty=faculty1    class_name=cs1    student=student2
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Class Does Not Contain Student    faculty1    cs1    student2
+    Add To Class CSV    faculty=faculty1    class_name=cs1    username=student2
+    Gkeep Modify Succeeds    faculty=faculty1    class_name=cs1
+    Class Contains Student    faculty1    cs1    student2
diff --git a/tests/acceptance/gkeep_test.robot b/tests/acceptance/gkeep_test.robot
index f28920ea..5ab8961a 100644
--- a/tests/acceptance/gkeep_test.robot
+++ b/tests/acceptance/gkeep_test.robot
@@ -37,6 +37,13 @@ Test Produces Email to Faculty
     Gkeep Test Succeeds    faculty1    cs1    good_simple    good_simple/correct_solution
     Submission Test Results Email Exists    faculty1    cs1    good_simple    Done
 
+
+Two Tests Produce Two Emails to Faculty
+    Gkeep Test Succeeds    faculty1    cs1    good_simple    good_simple/correct_solution
+    Gkeep Test Succeeds    faculty1    cs1    good_simple    good_simple/correct_solution
+    Verify Submission Test Result Count    faculty1    cs1    good_simple    Done    2
+
+
 Test Fails on Bad Course Name
     [Tags]  error
     Gkeep Test Fails    faculty1    bad_course    good_simple    good_simple/correct_solution
diff --git a/tests/acceptance/gkserver_base/Vagrantfile b/tests/acceptance/gkserver_base/Vagrantfile
index 8dbbc93f..84030bb1 100644
--- a/tests/acceptance/gkserver_base/Vagrantfile
+++ b/tests/acceptance/gkserver_base/Vagrantfile
@@ -14,7 +14,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 Vagrant.configure("2") do |config|
-    config.vm.box = "ubuntu/xenial64"
+    config.vm.box = "ubuntu/bionic64"
     config.vm.provision "shell", inline:<<-SCRIPT
         useradd -ms /bin/bash keeper
         usermod -aG keeper keeper
diff --git a/tests/acceptance/gkserver_base/mysmtpd.py b/tests/acceptance/gkserver_base/mysmtpd.py
index 411283f9..8b774e98 100644
--- a/tests/acceptance/gkserver_base/mysmtpd.py
+++ b/tests/acceptance/gkserver_base/mysmtpd.py
@@ -50,12 +50,13 @@ def __init__(self, port, directory):
         :return: None
         """
 
-        smtpd.DebuggingServer.__init__(self, ('localhost', port), None)
+        smtpd.DebuggingServer.__init__(self, ('localhost', port), None,
+                                       decode_data=True)
         self.directory = directory
 
         self.users = {}
 
-    def process_message(self, peer, mailfrom, rcpttos, data):
+    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
         """
         Process one message (save it to the appropriate file
 
@@ -104,4 +105,4 @@ def main():
 
     asyncore.loop()
 
-main()
\ No newline at end of file
+main()
diff --git a/tests/acceptance/manual_configure.py b/tests/acceptance/manual_configure.py
index babd7ce8..21c14794 100644
--- a/tests/acceptance/manual_configure.py
+++ b/tests/acceptance/manual_configure.py
@@ -71,7 +71,7 @@
 
 print('Student1 Clones and Submits Assignment')
 client.clone_assignment('student1', 'prof1', 'cs1', 'good_simple')
-client.student_submits_correct_solution('student1', 'prof1', 'cs1', 'good_simple')
+client.student_submits('student1', 'prof1', 'cs1', 'good_simple', 'correct_solution')
 
 print('Prof1 fetches assignment')
 client.fetch_assignment('prof1', 'cs1', 'good_simple', 'fetched_assignments')
\ No newline at end of file
diff --git a/tests/acceptance/student_submit.robot b/tests/acceptance/student_submit.robot
index 1bf9acfa..55de3fa9 100644
--- a/tests/acceptance/student_submit.robot
+++ b/tests/acceptance/student_submit.robot
@@ -36,16 +36,25 @@ Student Submits Correct Solution
     Gkeep Upload Succeeds   faculty1   cs1    good_simple
     Gkeep Publish Succeeds  faculty1    cs1     good_simple
     Clone Assignment  student1  faculty1    cs1     good_simple
-    Student Submits Correct Solution    student1    faculty1    cs1    good_simple
+    Student Submits    student1    faculty1    cs1    good_simple    correct_solution
     Submission Test Results Email Exists    student1    cs1    good_simple    Done
 
+Student Submit From Other Branch
+    [Tags]  error
+    Add Assignment to Client  faculty1  good_simple
+    Gkeep Upload Succeeds   faculty1   cs1    good_simple
+    Gkeep Publish Succeeds  faculty1    cs1     good_simple
+    Clone Assignment  student1  faculty1    cs1     good_simple
+    Student Submits    student1    faculty1    cs1     good_simple    correct_solution    branch=other    expect_failure=True
+    Submission Test Results Email Does Not Exist    student1    cs1     good_simple     Done
+
 Student Submits Python Action Solution
     [Tags]  happy_path
     Add Assignment to Client  faculty1  good_action_py
     Gkeep Upload Succeeds   faculty1   cs1    good_action_py
     Gkeep Publish Succeeds  faculty1    cs1     good_action_py
     Clone Assignment  student1  faculty1    cs1     good_action_py
-    Student Submits Correct Solution    student1    faculty1    cs1    good_action_py
+    Student Submits    student1    faculty1    cs1    good_action_py    correct_solution
     Submission Test Results Email Exists    student1    cs1    good_action_py    Done
 
 Bad Action.sh
@@ -54,7 +63,7 @@ Bad Action.sh
     Gkeep Upload Succeeds   faculty1   cs1    bad_action
     Gkeep Publish Succeeds  faculty1    cs1     bad_action
     Clone Assignment  student1  faculty1    cs1     bad_action
-    Student Submits Correct Solution    student1    faculty1    cs1    bad_action
+    Student Submits    student1    faculty1    cs1    bad_action    correct_solution
     Email Exists    student1    subject_contains="bad_action: Failed to process submission - contact instructor"    body_contains="instructor"
 
 Duplicate Assignment Name Across Courses Managed
@@ -65,13 +74,13 @@ Duplicate Assignment Name Across Courses Managed
     Gkeep Upload Succeeds   faculty1   cs1    good_simple
     Gkeep Publish Succeeds  faculty1    cs1     good_simple
     Clone Assignment  student1  faculty1    cs1     good_simple
-    Student Submits Correct Solution    student1    faculty1    cs1    good_simple
+    Student Submits    student1    faculty1    cs1    good_simple    correct_solution
     Submission Test Results Email Exists    student1    cs1    good_simple    Done
     # good_simple used in cs2
     Gkeep Upload Succeeds   faculty1   cs2    good_simple
     Gkeep Publish Succeeds  faculty1    cs2     good_simple
     Clone Assignment  student1  faculty1    cs2     good_simple
-    Student Submits Correct Solution    student1    faculty1    cs2    good_simple
+    Student Submits    student1    faculty1    cs2    good_simple    correct_solution
     Submission Test Results Email Exists    student1    cs2    good_simple    Done
 
 Submissions While Gkeepd is Down
@@ -82,8 +91,17 @@ Submissions While Gkeepd is Down
     Clone Assignment  student1  faculty1    cs1     good_simple
     Stop Gkeepd
     gkeepd is not running
-    Student Submits Correct Solution    student1    faculty1    cs1    good_simple
+    Student Submits    student1    faculty1    cs1    good_simple    correct_solution
     Submission Test Results Email Does Not Exist    student1    cs1    good_simple    Done
     Start gkeepd
     Submission Test Results Email Exists    student1    cs1    good_simple    Done
 
+Global Timeout Catches Infinite Loop
+    [Tags]    happy_path
+    Add Assignment to Client  faculty1  run_py
+    Gkeep Upload Succeeds   faculty1   cs1    run_py
+    Gkeep Publish Succeeds  faculty1    cs1     run_py
+    Clone Assignment  student1  faculty1    cs1     run_py
+    Student Submits    student1    faculty1    cs1    run_py    infinite_loop_submission
+    Email Exists    student1    subject_contains="run_py: Failed to process submission - contact instructor"    body_contains="instructor"
+    Email Exists    faculty1    subject_contains="git-keeper run_tests failure"    body_contains="further information"
diff --git a/tests/acceptance/vm_scripts/email_to_count.py b/tests/acceptance/vm_scripts/email_to_count.py
new file mode 100644
index 00000000..77906382
--- /dev/null
+++ b/tests/acceptance/vm_scripts/email_to_count.py
@@ -0,0 +1,46 @@
+# Copyright 2022 Nathan Sommer and Ben Coleman
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from gkeeprobot.dectorators import polling
+import glob
+import sys
+
+
+@polling
+def check_email_count(to, expected_count, subject_contains=None, body_contains=None):
+    count = 0
+    for file in glob.glob('/email/{}_*.txt'.format(to)):
+        if check_email_contains(file, subject_contains=subject_contains, body_contains=body_contains):
+            count += 1
+
+    return count == expected_count
+
+def check_email_contains(filename, subject_contains=None, body_contains=None):
+    with open(filename) as f:
+        from_line = f.readline()
+        subject_line = f.readline()
+        body = f.read()
+
+        if subject_contains is not None and subject_contains not in subject_line:
+            return False
+
+        if body_contains is not None and body_contains not in body:
+            return False
+
+        return True
+
+
+print(check_email_count(sys.argv[1], int(sys.argv[4]), subject_contains=sys.argv[2], body_contains=sys.argv[3]))
diff --git a/tests/acceptance/vm_scripts/reset_client.py b/tests/acceptance/vm_scripts/reset_client.py
index b98ff8cb..aa9abb9a 100644
--- a/tests/acceptance/vm_scripts/reset_client.py
+++ b/tests/acceptance/vm_scripts/reset_client.py
@@ -14,7 +14,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import os
-from gkeepcore.shell_command import run_command
+from gkeepcore.shell_command import run_command, CommandError
 
 
 def remove_users():
@@ -23,6 +23,11 @@ def remove_users():
 
     for user in os.listdir('/home'):
         if user not in expected:
+            try:
+                run_command('sudo pkill -9 -u {}'.format(user))
+            except CommandError:
+                pass
+
             run_command('sudo userdel -r {}'.format(user))
 
 
diff --git a/tests/acceptance/vm_scripts/reset_server.py b/tests/acceptance/vm_scripts/reset_server.py
index a7df2658..19669801 100644
--- a/tests/acceptance/vm_scripts/reset_server.py
+++ b/tests/acceptance/vm_scripts/reset_server.py
@@ -58,6 +58,11 @@ def remove_users():
 
     for user in os.listdir('/home'):
         if user not in expected:
+            try:
+                run_command('sudo pkill -9 -u {}'.format(user))
+            except CommandError:
+                pass
+
             run_command('sudo userdel -r {}'.format(user))