diff --git a/api/canvas_classes.py b/api/canvas_classes.py
index c05ab63ecfb05140310f5657030ca62feadcffcf..69b0de55f588b86dbb68e506bb666d4d99da2b31 100644
--- a/api/canvas_classes.py
+++ b/api/canvas_classes.py
@@ -1,4 +1,4 @@
-from typing import ClassVar, Dict, Iterable, List, Union
+from typing import ClassVar, Dict, Iterable, List, Optional, Union
 
 from canvasapi import Canvas
 from canvasapi.assignment import Assignment, AssignmentGroup
@@ -14,11 +14,11 @@ from config import Config
 class CanvasSession:
     __instance: ClassVar[Canvas] = None
 
-    @staticmethod
-    def get_session() -> Canvas:
-        if CanvasSession.__instance is None:
-            CanvasSession.__instance = Canvas(Config.canvas_url, Config.canvas_api_key)
-        return CanvasSession.__instance
+    @classmethod
+    def get_session(cls) -> Canvas:
+        if cls.__instance is None:
+            cls.__instance = Canvas(Config.canvas_url, Config.canvas_api_key)
+        return cls.__instance
 
 
 #   PEOPLE CLASSES
@@ -75,6 +75,9 @@ class CanvasUser:
     def __ne__(self, other: "CanvasUser") -> bool:
         return not self.__eq__(other)
 
+    def __hash__(self) -> int:
+        return hash(self.get_name())
+
 
 """
 // A Canvas user, e.g. a student, teacher, administrator, observer, etc.
@@ -306,7 +309,7 @@ class CanvasAssignment:
     def is_quiz(self) -> bool:
         return 'online_quiz' in self.canvas_assignment.submission_types
 
-    def get_submission_text(self, canvas_user: CanvasUser) -> Union[str, None]:
+    def get_submission_text(self, canvas_user: CanvasUser) -> Optional[str]:
         submission: Submission = self.canvas_assignment.get_submission(canvas_user.get_canvas_id())
         if submission.submission_type == 'online_text_entry':
             return submission.body  # TODO: what happens if there is no user submission?
diff --git a/api/composite_user.py b/api/composite_user.py
index 257114b23c27d029f9e1b6962b4c19da56fbd77f..7a380a5c957247fe93099cc8fa8f19d12c304091 100644
--- a/api/composite_user.py
+++ b/api/composite_user.py
@@ -1,5 +1,5 @@
 import csv
-from typing import ClassVar, Collection, Dict, Set, List
+from typing import ClassVar, Collection, Dict, List, Set
 
 from api.canvas_classes import CanvasUser
 from api.canvas_classes import CanvasCourse
@@ -42,9 +42,22 @@ class CompositeUser:
         CompositeUser.instances[self.canvas_username] = self
         CompositeUser.instances[self.gitlab_username] = self
 
+    @classmethod
+    def initialize_composite_user(cls, canvas_student: CanvasUser, gitlab_student: GitlabUser) -> "CompositeUser":
+        student: Dict[str, str] = {'SortableName': canvas_student.get_sortable_name(),
+                                   'ReadableName': canvas_student.get_name(),
+                                   'NUID': str(canvas_student.get_nuid()),
+                                   'CanvasUsername': canvas_student.get_username(),
+                                   'CanvasEmail': canvas_student.get_email(),
+                                   'GitlabUsername': gitlab_student.get_username(),
+                                   'GitlabEmail': gitlab_student.get_email()}
+        return CompositeUser(student, [], [])
+
     def get_canvas_user(self) -> CanvasUser:
         if self.canvas_user is None:
-            self.canvas_user = CanvasUser(self.NUID)  # n.b., can retrieve own user but not arbitrary user
+            # self.canvas_user = CanvasUser(self.NUID)  # n.b., can retrieve own user but not arbitrary user
+            all_students: List[CanvasUser] = CanvasCourse(Course.canvas_course_id).get_students()
+            self.canvas_user = list(filter(lambda s: s.get_username() == self.canvas_username, all_students))[0]
         return self.canvas_user
 
     def get_gitlab_user(self) -> GitlabUser:
@@ -52,13 +65,16 @@ class CompositeUser:
             self.gitlab_user = GitlabUser(self.gitlab_username)
         return self.gitlab_user
 
+    def set_gitlab_email(self, email: str) -> None:
+        self.gitlab_email = email
+
     def tentatively_pair_with(self, username: str) -> None:
         self.candidate_teammates = {username}
 
     def tentatively_team_with(self, usernames: Collection[str]) -> None:
         self.candidate_teammates = set(usernames)
 
-    def commit_team(self) -> None:                      # TODO: Will want to re-visit this based on tracking by project
+    def commit_team(self) -> None:  # TODO: Will want to re-visit this based on tracking by project
         self.graylist = self.graylist.union(self.candidate_teammates)
 
     def discard_team(self) -> None:
@@ -94,9 +110,9 @@ class CompositeUser:
     def __hash__(self) -> int:
         return hash(self.canvas_username)
 
-    @staticmethod
-    def get_user(username_or_email: str) -> "CompositeUser":
-        return CompositeUser.instances[username_or_email]
+    @classmethod
+    def get_user(cls, username_or_email: str) -> "CompositeUser":
+        return cls.instances[username_or_email]
 
     # TODO: Will want to re-visit these based on tracking graylist by project...
     @staticmethod
diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py
index f129c66840936e87d19ed77f29d830bd2862a5c9..90ab56b513dc2c5b7b6bca88551dc78b6a7127ff 100644
--- a/api/gitlab_classes.py
+++ b/api/gitlab_classes.py
@@ -1,5 +1,5 @@
 from datetime import datetime
-from typing import ClassVar, Dict, Iterable, List, Set, Union
+from typing import ClassVar, Dict, Iterable, List, Optional, Set, Union
 
 from gitlab import Gitlab, MAINTAINER_ACCESS
 from gitlab.v4.objects import Issue, Project, ProjectCommit, User
@@ -10,11 +10,11 @@ from config import Config
 class GitlabSession:
     __instance: ClassVar[Gitlab] = None
 
-    @staticmethod
-    def get_session() -> Gitlab:
-        if GitlabSession.__instance is None:
-            GitlabSession.__instance = Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
-        return GitlabSession.__instance
+    @classmethod
+    def get_session(cls) -> Gitlab:
+        if cls.__instance is None:
+            cls.__instance = Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
+        return cls.__instance
 
 
 class GitlabUser:
@@ -43,6 +43,13 @@ class GitlabUser:
     def get_username(self) -> str:
         return self.git_user.username
 
+    def get_email(self) -> Optional[str]:
+        # return self.git_user.emails.list()[0]   # list() is forbidden
+        try:
+            return self.git_user.email
+        except AttributeError:
+            return None
+
     def get_site(self) -> str:
         return self.git_user.web_url
 
@@ -119,7 +126,7 @@ class GitlabIssue:
             updated_at = updated_at[:-1] + '+00:00'
         return datetime.fromisoformat(updated_at)
 
-    def get_closed_at(self) -> Union[datetime, None]:
+    def get_closed_at(self) -> Optional[datetime]:
         """
         :return: an "aware" datetime object representing the last date/time the issue was closed, or None if the issue
         is open
diff --git a/common_functions.py b/common_functions.py
new file mode 100644
index 0000000000000000000000000000000000000000..d902378881f3d5c2628b4300569563e260fac22d
--- /dev/null
+++ b/common_functions.py
@@ -0,0 +1,33 @@
+import datetime
+from typing import List, Optional, TypeVar
+
+ChoiceType = TypeVar("ChoiceType")
+
+
+def select_from_list(choices: List[ChoiceType], choice_name: str) -> ChoiceType:
+    print(f'Choose the {choice_name} from this list:')
+    for i in range(len(choices)):
+        print(f'{i+1})\t{choices[i]}'.expandtabs(4))
+    selection = input('Enter selection: ')
+    return choices[int(selection) - 1]
+
+
+def strip_html(text: Optional[str]) -> Optional[str]:
+    import re
+    if text is not None:
+        return re.sub(re.compile('<.*?>'), '', text)
+    else:
+        return None
+
+def semester_stamp() -> str:
+    today = datetime.date.today()
+    year: int = today.year
+    month: int = today.month
+    # Normalize month to semester start
+    if month < 5:
+        month = 1
+    elif month < 8:
+        month = 5
+    else:
+        month = 8
+    return f'{year}-0{month}'
diff --git a/grade_team_contribution.py b/grade_team_contribution.py
index 6f522aaba965625dea87d4c74f90aa51332d10e7..95cbbdb6c652ad0ee1a5eea39387ef22be2a0cd5 100644
--- a/grade_team_contribution.py
+++ b/grade_team_contribution.py
@@ -1,21 +1,14 @@
 import textwrap
+from typing import List
 
 from api.canvas_classes import *
 from api.gitlab_classes import *
+from common_functions import select_from_list, strip_html
 from course import Course
 
 
-def structure_text(text):
-    import re
-    return textwrap.wrap(re.sub(re.compile('<.*?>'), '', text))
-
-
-def select_from_list(choices, choice_name):
-    print(f'Choose the {choice_name} from this list:')
-    for i in range(len(choices)):
-        print(f'{i+1})\t{choices[i]}'.expandtabs(4))
-    selection = input('Enter selection: ')
-    return choices[int(selection)-1]
+def structure_text(text: str) -> List[str]:
+    return textwrap.wrap(strip_html(text))
 
 
 def display_peer_reviews(assignment, students):
diff --git a/initialize_student_tracking.py b/initialize_student_tracking.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a5bfc77e02354765db34b8e6f38326a0892fef9
--- /dev/null
+++ b/initialize_student_tracking.py
@@ -0,0 +1,187 @@
+import datetime
+import subprocess
+from typing import Dict, List, Optional, Set
+
+from gitlab.exceptions import GitlabGetError, GitlabListError
+
+from api.canvas_classes import CanvasCourse, CanvasAssignment, CanvasUser
+from api.composite_user import CompositeUser
+from api.gitlab_classes import GitlabUser, GitlabProject, GitlabCommit
+from common_functions import select_from_list, strip_html, semester_stamp
+from course import Course
+
+DEFAULT_HOMEWORK_REPOSITORY = 'csce361-homework'
+
+
+def get_responses(assignment: CanvasAssignment, students: List[CanvasUser]) -> Dict[CanvasUser, str]:
+    # TODO: Can this be made more general and be useful to grade_team_contribution.display_peer_reviews() ?
+    student_responses: Dict[CanvasUser, Optional[str]] = {}
+    # noinspection PyUnusedLocal
+    student: CanvasUser
+    for student in students:
+        student_responses[student] = strip_html(assignment.get_submission_text(student))
+    return student_responses
+
+
+def extract_gitlab_usernames(student_responses: Dict[CanvasUser, str]) -> Dict[CanvasUser, str]:
+    student_gitlab_usernames: Dict[CanvasUser, str] = {}
+    # noinspection PyUnusedLocal
+    student: CanvasUser
+    for student in student_responses:
+        response: str = student_responses[student]
+        if response is None:
+            response = ''
+        candidate_username: List[str] = response.split()
+        if len(candidate_username) == 1:
+            username: str = candidate_username[0]
+            if username[0] == '@':
+                student_gitlab_usernames[student] = username[1:]
+            else:
+                student_gitlab_usernames[student] = username
+        elif len(candidate_username) > 1:
+            print(f'Unexpected response from {student.get_name()}: {response}')
+            potential_usernames = list(filter(lambda s: s[0] == '@', candidate_username))
+            if len(potential_usernames) == 1:
+                username: str = potential_usernames[0]
+                print(f'Assigning {username} as username.')
+                student_gitlab_usernames[student] = username[1:]
+            else:
+                username: str = input('Enter gitlab username: ')
+                student_gitlab_usernames[student] = username
+        else:
+            print(f'{student.get_name()} has no response; assigning blank gitlab username.')
+            student_gitlab_usernames[student] = ''
+        print(f'\tCanvas username: {student.get_username()}\tGitlab username: @{student_gitlab_usernames[student]}')
+    return student_gitlab_usernames
+
+
+def retrieve_gitlab_user(gitlab_username: str, student_name: str) -> Optional[GitlabUser]:
+    try:
+        if gitlab_username != '':
+            return GitlabUser(gitlab_username)
+        else:
+            return None
+    except IndexError:
+        print(f'\t{student_name} has an invalid gitlab username: {gitlab_username}.')
+        new_username: str = input('\tEnter new gitlab username (enter blank to skip): ')
+        return retrieve_gitlab_user(new_username, student_name)
+
+
+# noinspection PyShadowingNames
+def create_composite_users(student_usernames: Dict[CanvasUser, str]) -> (Set[CompositeUser], Set[CanvasUser]):
+    composite_users: Set[CompositeUser] = set()
+    skipped_users: Set[CanvasUser] = set()
+    # noinspection PyUnusedLocal
+    canvas_student: CanvasUser
+    for canvas_student in student_usernames:
+        gitlab_username: str = student_usernames[canvas_student]
+        gitlab_student: GitlabUser = retrieve_gitlab_user(gitlab_username, canvas_student.get_name())
+        if gitlab_student is not None:
+            composite_user: CompositeUser = CompositeUser.initialize_composite_user(canvas_student, gitlab_student)
+            composite_users.add(composite_user)
+        else:
+            print(f'{canvas_student.get_name()} has a blank gitlab username; skipping.')
+            skipped_users.add(canvas_student)
+    return composite_users, skipped_users
+
+
+def retrieve_homework_repos(students: Set[CompositeUser], repo_name: str) \
+        -> Dict[CompositeUser, Optional[GitlabProject]]:
+    user_repos: Dict[CompositeUser, GitlabProject] = {}
+    for student in students:
+        path = student.get_gitlab_user().get_username() + '/' + repo_name
+        try:
+            print(f'\tRetrieving {path}.git at {datetime.datetime.now()}.')
+            repo = GitlabProject(path)
+        except GitlabGetError:
+            print(f'*** WARNING *** Could not retrieve {path}.git!')
+            repo = None
+        user_repos[student] = repo
+    return user_repos
+
+
+def add_gitlab_email(user_repos: Dict[CompositeUser, Optional[GitlabProject]]) -> None:
+    # noinspection PyUnusedLocal
+    student: CompositeUser
+    for student in user_repos:
+        project = user_repos[student]
+        if project is not None:
+            try:
+                commits: List[GitlabCommit] = project.get_commits()
+            except GitlabListError:
+                print(f'*** NOTE *** Could not retrieve gitlab email for {student.get_canvas_user().get_name()}. '
+                      f'This probably indicates Guest permissions.')
+                commits = []
+            if len(commits) > 0:
+                commit = commits[-1]  # should be the initial commit; regardless, all commits should be by the student
+                email = commit.get_author()['email']
+                student.set_gitlab_email(email)
+
+
+def create_cloning_script(user_repos: Dict[CompositeUser, Optional[GitlabProject]],
+                          no_username: Set[CanvasUser]) -> None:
+    filename = f'{semester_stamp()}-homework.sh'
+    file = open(filename, 'x')
+    file.write('#!/bin/bash\n\n')
+    file.write('# Auto-generated clone script.\n')
+    no_repo: Set[CompositeUser] = set()
+    # noinspection PyUnusedLocal
+    composite_student: CompositeUser
+    for composite_student in user_repos:
+        repo = user_repos[composite_student]
+        if repo is not None:
+            repo_url = repo.get_cloning_url()
+            student_name = composite_student.get_canvas_user().get_name().replace(' ', '_')
+            file.write(f'git clone {repo_url} {student_name}')
+            if composite_student.gitlab_email is None:  # breaking encapsulation?
+                file.write('  # may have inadequate permissions')
+            file.write('\n')
+        else:
+            no_repo.add(composite_student)
+    if len(no_repo) > 0:
+        file.write('\n# Students without homework repositories '
+                   '(check for misspelled repository names or repositories created but not shared):\n')
+    for composite_student in no_repo:
+        student_name = composite_student.get_canvas_user().get_name().replace(' ', '_')
+        file.write(f'# git clone {composite_student.get_gitlab_user().get_username()}/... {student_name}\n')
+    if len(no_username) > 0:
+        file.write('\n# Students without valid gitlab usernames (check for repositories shared but username not '
+                   'submitted):\n')
+    # noinspection PyUnusedLocal
+    canvas_student: CanvasUser
+    for canvas_student in no_username:
+        student_name = canvas_student.get_name().replace(' ', '_')
+        file.write(f'# git clone ... {student_name}\n')
+    file.close()
+    subprocess.call(['chmod', '+x', filename])
+    print(f'\tCreated {filename}. Distribute to TAs so they can clone the homework repositories.')
+
+
+if __name__ == '__main__':
+    course = CanvasCourse(Course.canvas_course_id)
+    print('First, select the "setup" assignment to extract student information from.\n')
+    assignment_groups = course.get_assignment_groups()
+    assignment_group = select_from_list(assignment_groups, 'assignment group')
+    print()
+    assignments = assignment_group.get_assignments()
+    setup_assignment = select_from_list(assignments, 'assignment')
+    print(f'\nWe will now extract student information from {setup_assignment}.')
+    responses = get_responses(setup_assignment, course.get_students())
+    gitlab_usernames = extract_gitlab_usernames(responses)
+    print('\nWe will now check the validity of the gitlab usernames.')
+    composite_users, skipped_users = create_composite_users(gitlab_usernames)
+    if len(skipped_users) == 0:
+        print(f'\nSuccessfully retrieved {len(composite_users)} students.')
+    else:
+        print(
+            f'\n*** WARNING *** Skipped {len(skipped_users)} '
+            f'out of {len(skipped_users)+len(composite_users)} students!')
+    print('We will now retrieve the students\' homework repositories.')
+    choice = input(f'Enter homework repository name [{DEFAULT_HOMEWORK_REPOSITORY}]: ')
+    if choice == '':
+        choice = DEFAULT_HOMEWORK_REPOSITORY
+    homework_repos = retrieve_homework_repos(composite_users, choice)
+    add_gitlab_email(homework_repos)
+    print('\nCreating cloning script for homework repositories.')
+    create_cloning_script(homework_repos, skipped_users)
+    # create_csv(homework_repos)