Repository: Justintime50/github-archive

Issue:
Transition all Env Variables to CLI Flags
For better control and flexibility, let's move all env variables to CLI flags and rework the whole app to properly pass those variables around as class variables instead of parameters to each function.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47d277a..be09384 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,15 +6,18 @@
 
 * The `--user_clone` and `--user_pull` flags are now titled `--personal_clone` and `--personal_pull` as the new `--user_clone` and `--user_pull` flags are used for a list of specified users
 * Removes the `GITHUB_ARCHIVE_BUFFER` environment variable in favor of the new `--threads` flag
+* Reworks the entire app config to use CLI flags solely instead of a mix of CLI flags and env variables, see the README for new usage instructions or run `github-archive --help` (closes #30)
+* Repos or gists that fail to clone or pull will now be completely removed so that they can be retried from scratch on the next run of the tool. This was an especially important change for bad clones as the tool would previously leave an empty initialized git folder even if the clone failed which would not possess the actual git repo yet. In this state, you could not properly pull new changes because the content of the repo hadn't properly been cloned yet
 
 ### Features
 
 * Adds the ability to specify a list of users via `GITHUB_ARCHIVE_USERS` to clone and pull repos for via the `--users_clone` and `--users_pull` flags (closes #20)
 * Added the `--threads` flag which can specify the number of concurrent threads to run at once (closes #22)
+* Adds a new `--view` flag which allows you to "dry run" the application, seeing the entire list of repos and gists based on the input provided (closes #25)
 
 ### Fixes
 
-* Removed verbose logging of skipped actions (relegated them to the debugging logger). Added additional debug logging and user-readable logging related to API calls 
+* Removed verbose logging of skipped actions and "Already up to date" messages. Added additional logging related to API calls
 * Adds proper validation of the `GITHUB_ARCHIVE_ORGS` variable on startup
 * Various code refactor, bug fixes, and optimizations
 * Bumped the default git operation timeout from 180 seconds to 300 seconds for larger repos (closes #22)
diff --git a/README_v4.md b/README_v4.md
index bf20c75..a5d8652 100644
--- a/README_v4.md
+++ b/README_v4.md
@@ -2,7 +2,7 @@
 
 # GitHub Archive
 
-A powerful script to concurrently clone your entire GitHub instance or save it as an archive.
+A powerful tool to concurrently clone or pull user and org repos and gists to create a GitHub archive.
 
 [![Build Status](https://github.com/Justintime50/github-archive/workflows/build/badge.svg)](https://github.com/Justintime50/github-archive/actions)
 [![Coverage Status](https://coveralls.io/repos/github/Justintime50/github-archive/badge.svg?branch=main)](https://coveralls.io/github/Justintime50/github-archive?branch=main)
@@ -64,33 +64,27 @@ ssh-add
 **Access**: GitHub Archive can only clone or pull repos that the authenticated user has access to. That means that private repos from another user or orgs that you don't have access to will not be able to be cloned or pulled.
 
 ```
-Basic Usage:
-    GITHUB_TOKEN=123... github-archive --personal-clone --personal-pull
-
-Advanced Usage:
-    GITHUB_TOKEN=123... GITHUB_ARCHIVE_ORGS="org1, org2" GITHUB_ARCHIVE_LOCATION="~/custom_location" \
-    github-archive -pc -pp -uc -up -gc -gp -oc -op
+Usage:
+    github-archive --users justintime50 --clone
 
 Options:
     -h, --help            show this help message and exit
-    -pc, --personal_clone Clone personal repos.
-    -pp, --personal_pull  Pull personal repos
-    -uc, --user_clone     Clone user repos.
-    -up, --user_pull      Pull user repos.
-    -gc, --gists_clone    Clone personal gists.
-    -gp, --gists_pull     Pull personal gists.
-    -oc, --orgs_clone     Clone organization repos.
-    -op, --orgs_pull      Pull organization repos.
-    -t, --threads         The number of concurrent threads to run. (default is 10)
-
-Environment Variables:
-    GITHUB_TOKEN                    expects a string of your GitHub Token
-    GITHUB_ARCHIVE_USERS            expects a string of comma separated GitHub usernames. eg: "user1, user2"
-    GITHUB_ARCHIVE_ORGS             expects a string of comma separated GitHub organizations. eg: "org1, org2"
-    GITHUB_ARCHIVE_LOCATION         expects a string of an explicit location on your machine (eg: "~/custom_location"). Default: ~/github-archive
-    GITHUB_ARCHIVE_TIMEOUT          expects an int for the number of seconds before a git operation times out. Default: 300
-    GITHUB_ARCHIVE_LOG_MAX_BYTES    expects an int of the max bytes that a log will grow to. Once the log exceeds this number, it will rollover to another log. Default: 200000
-    GITHUB_ARCHIVE_LOG_BACKUP_COUNT expects an int of the number of logs to rollover once a single log exceeds the max bytes size. Default: 5
+    -v, --view            Pass this flag to view git assets (dry run).
+    -c, --clone           Pass this flag to clone git assets.
+    -p, --pull            Pass this flag to pull git assets.
+    -u USERS, --users USERS
+                            Pass a comma separated list of users to get repos for.
+    -o ORGS, --orgs ORGS  Pass a comma separated list of orgs to get repos for.
+    -g GISTS, --gists GISTS
+                            Pass a comma separated list of users to get gists for.
+    -to TIMEOUT, --timeout TIMEOUT
+                            The number of seconds before a git operation times out.
+    -th THREADS, --threads THREADS
+                            The number of concurrent threads to run.
+    -t TOKEN, --token TOKEN
+                            Provide your GitHub token to authenticate with the GitHub API and gain access to private repos and gists.
+    -l LOCATION, --location LOCATION
+                            The location where you want your GitHub Archive to be stored.
 ```
 
 ## Development
@@ -108,7 +102,3 @@ make coverage
 # Run the tool locally
 venv/bin/python github_archive/cli.py --help
 ```
-
-## Legacy Script
-
-This tool was initially built in Bash and later re-written in Python. If you'd like to use or view the legacy script, check out the separate [Legacy README](legacy/README.md).
diff --git a/github_archive/archive.py b/github_archive/archive.py
index e54da8e..70d7f5e 100644
--- a/github_archive/archive.py
+++ b/github_archive/archive.py
@@ -1,25 +1,15 @@
 import logging
 import os
+import shutil
 import subprocess
 from datetime import datetime
 from threading import BoundedSemaphore, Thread
 
 from github import Github
 
+from github_archive.constants import DEFAULT_LOCATION, DEFAULT_NUM_THREADS, DEFAULT_TIMEOUT
 from github_archive.logger import Logger
 
-GITHUB_TOKEN = os.getenv('GITHUB_TOKEN')
-GITHUB_LOGIN = Github(GITHUB_TOKEN)
-AUTHENTICATED_GITHUB_USER = GITHUB_LOGIN.get_user()
-ORG_LIST = os.getenv('GITHUB_ARCHIVE_ORGS', '')
-ORGS = ORG_LIST.split(',')
-USER_LIST = os.getenv('GITHUB_ARCHIVE_USERS', '')
-USERS = USER_LIST.split(',')
-
-GIT_TIMEOUT = int(os.getenv('GITHUB_ARCHIVE_TIMEOUT', 300))
-GITHUB_ARCHIVE_LOCATION = os.path.expanduser(os.getenv('GITHUB_ARCHIVE_LOCATION', '~/github-archive'))
-DEFAULT_NUM_THREADS = 10
-
 LOGGER = logging.getLogger(__name__)
 CLONE_OPERATION = 'clone'
 ORG_CONTEXT = 'orgs'
@@ -29,117 +19,148 @@ USER_CONTEXT = 'user'
 
 
 class GithubArchive:
-    @staticmethod
-    def run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        num_of_threads=DEFAULT_NUM_THREADS,
+    def __init__(
+        self,
+        view=False,
+        clone=False,
+        pull=False,
+        users=None,
+        orgs=None,
+        gists=None,
+        timeout=DEFAULT_TIMEOUT,
+        threads=DEFAULT_NUM_THREADS,
+        token=None,
+        location=DEFAULT_LOCATION,
     ):
-        """Run the tool based on the arguments passed."""
-        GithubArchive.initialize_project(users_clone, users_pull, orgs_clone, orgs_pull)
-        Logger._setup_logging(LOGGER)
+        # Parameter variables
+        self.view = view
+        self.clone = clone
+        self.pull = pull
+        self.users = users.lower().split(',') if users else ''
+        self.orgs = orgs.lower().split(',') if orgs else ''
+        self.gists = gists.lower().split(',') if gists else ''
+        self.timeout = timeout
+        self.threads = threads
+        self.token = token
+        self.location = location
+        # Internal variables
+        self.github_instance = Github(self.token) if self.token else Github()
+        self.authenticated_user = self.github_instance.get_user() if self.token else None
+        self.authenticated_username = self.authenticated_user.login.lower() if self.token else None
+
+    def run(self):
+        """Run the tool based on the arguments passed via the CLI."""
+        self.initialize_project()
+        Logger.setup_logging(LOGGER, self.location)
         LOGGER.info('# GitHub Archive started...\n')
         start_time = datetime.now()
 
-        # Make API calls
-        # TODO: If we decide to remove the `personal` flag here, we need a new way to get private repos
-        # for the authenticated user. We could do this by making a separate API call to the get_user endpoint
-        # of the users passing the API key based on the name in the `users` list and the authed user.
-        if personal_clone or personal_pull:
+        # Personal (includes personal authenticated items)
+        if self.token and self.authenticated_user_in_users and self.users:
             LOGGER.info('# Making API call to GitHub for personal repos...\n')
-            personal_repos = GithubArchive.get_personal_repos()
-        if users_clone or users_pull:
+            personal_repos = self.get_personal_repos()
+
+            if self.view:
+                LOGGER.info('# Viewing user repos...\n')
+                self.view_repos(personal_repos)
+            if self.clone:
+                LOGGER.info('# Cloning missing personal repos...\n')
+                self.iterate_repos_to_archive(personal_repos, PERSONAL_CONTEXT, CLONE_OPERATION)
+            if self.pull:
+                LOGGER.info('# Pulling changes to personal repos...\n')
+                self.iterate_repos_to_archive(personal_repos, PERSONAL_CONTEXT, PULL_OPERATION)
+
+            # We remove the authenticated user from the list so that we don't double pull their
+            # repos for the `users` logic.
+            self.users.remove(self.authenticated_username)
+
+        # Users (can include personal non-authenticated items)
+        if self.users and len(self.users) > 0:
             LOGGER.info('# Making API calls to GitHub for user repos...\n')
-            user_repos = GithubArchive.get_all_user_repos()
-        if orgs_clone or orgs_pull:
+            user_repos = self.get_all_user_repos()
+
+            if self.view:
+                LOGGER.info('# Viewing user repos...\n')
+                self.view_repos(user_repos)
+            if self.clone:
+                LOGGER.info('# Cloning missing user repos...\n')
+                self.iterate_repos_to_archive(user_repos, USER_CONTEXT, CLONE_OPERATION)
+            if self.pull:
+                LOGGER.info('# Pulling changes to user repos...\n')
+                self.iterate_repos_to_archive(user_repos, USER_CONTEXT, PULL_OPERATION)
+
+        # Orgs
+        if self.orgs:
             LOGGER.info('# Making API calls to GitHub for org repos...\n')
-            org_repos = GithubArchive.get_all_org_repos()
-        if gists_clone or gists_pull:
-            LOGGER.info('# Making API call to GitHub for personal gists...\n')
-            gists = GithubArchive.get_gists()
-
-        # Git operations
-        # TODO: Rework a class that can share all these values so we don't need to pass
-        # along all these variables across the app.
-        if personal_clone is True:
-            LOGGER.info('# Cloning missing personal repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, personal_repos, PERSONAL_CONTEXT, CLONE_OPERATION)
-        if personal_pull is True:
-            LOGGER.info('# Pulling personal repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, personal_repos, PERSONAL_CONTEXT, PULL_OPERATION)
-
-        if users_clone is True:
-            LOGGER.info('# Cloning missing user repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, user_repos, USER_CONTEXT, CLONE_OPERATION)
-        if users_pull is True:
-            LOGGER.info('# Pulling user repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, user_repos, USER_CONTEXT, PULL_OPERATION)
-
-        if orgs_clone is True:
-            LOGGER.info('# Cloning missing org repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, org_repos, ORG_CONTEXT, CLONE_OPERATION)
-        if orgs_pull is True:
-            LOGGER.info('# Pulling org repos...\n')
-            GithubArchive.iterate_repos_to_archive(num_of_threads, org_repos, ORG_CONTEXT, PULL_OPERATION)
-
-        if gists_clone is True:
-            LOGGER.info('# Cloning missing gists...\n')
-            GithubArchive.iterate_gists_to_archive(num_of_threads, gists, CLONE_OPERATION)
-        if gists_pull is True:
-            LOGGER.info('# Pulling gists...\n')
-            GithubArchive.iterate_gists_to_archive(num_of_threads, gists, PULL_OPERATION)
+            org_repos = self.get_all_org_repos()
+
+            if self.view:
+                LOGGER.info('# Viewing org repos...\n')
+                self.view_repos(org_repos)
+            if self.clone:
+                LOGGER.info('# Cloning missing org repos...\n')
+                self.iterate_repos_to_archive(org_repos, ORG_CONTEXT, CLONE_OPERATION)
+            if self.pull:
+                LOGGER.info('# Pulling changes to org repos...\n')
+                self.iterate_repos_to_archive(org_repos, ORG_CONTEXT, PULL_OPERATION)
+
+        # Gists
+        if self.gists:
+            LOGGER.info('# Making API call to GitHub for gists...\n')
+            gists = self.get_all_gists()
+
+            if self.view:
+                LOGGER.info('# Viewing gists...\n')
+                self.view_gists(gists)
+            if self.clone:
+                LOGGER.info('# Cloning missing gists...\n')
+                self.iterate_gists_to_archive(gists, CLONE_OPERATION)
+            if self.pull:
+                LOGGER.info('# Pulling changes to gists...\n')
+                self.iterate_gists_to_archive(gists, PULL_OPERATION)
 
         execution_time = f'Execution time: {datetime.now() - start_time}.'
         finish_message = f'GitHub Archive complete! {execution_time}\n'
         LOGGER.info(finish_message)
 
-    @staticmethod
-    def initialize_project(users_clone, users_pull, orgs_clone, orgs_pull):
-        """Initialize the tool and ensure everything is in order before running any logic."""
-        if not GITHUB_TOKEN:
-            message = 'GITHUB_TOKEN must be present to run github-archive.'
-            LOGGER.critical(message)
-            raise ValueError(message)
+    def initialize_project(self):
+        """Initialize the tool and ensure everything is in order before running any logic.
 
-        if not USER_LIST and (users_clone or users_pull):
-            message = 'GITHUB_ARCHIVE_USERS must be present when passing user flags.'
+        This function ensures the minimum set of requirements are passed in to run the tool:
+        1. a git operation
+        2. a list of assets to run operations on
+        """
+        if not os.path.exists(self.location):
+            os.makedirs(os.path.join(self.location, 'repos'))
+            os.makedirs(os.path.join(self.location, 'gists'))
+
+        if (self.users or self.orgs or self.gists) and not (self.view or self.clone or self.pull):
+            message = 'A git operation must be specified when a list of users or orgs is provided.'
             LOGGER.critical(message)
             raise ValueError(message)
-
-        if not ORG_LIST and (orgs_clone or orgs_pull):
-            message = 'GITHUB_ARCHIVE_ORGS must be present when passing org flags.'
+        elif not (self.users or self.orgs or self.gists) and (self.view or self.clone or self.pull):
+            message = 'A list must be provided when a git operation is specified.'
             LOGGER.critical(message)
             raise ValueError(message)
 
-        if not os.path.exists(GITHUB_ARCHIVE_LOCATION):
-            os.makedirs(os.path.join(GITHUB_ARCHIVE_LOCATION, 'repos'))
+    def authenticated_user_in_users(self):
+        return self.authenticated_user.login.lower() in self.users
 
-        if not os.path.exists(GITHUB_ARCHIVE_LOCATION):
-            os.makedirs(os.path.join(GITHUB_ARCHIVE_LOCATION, 'gists'))
-
-    @staticmethod
-    def get_personal_repos():
+    def get_personal_repos(self):
         """Retrieve all repos of the authenticated user."""
-        repos = AUTHENTICATED_GITHUB_USER.get_repos()
+        repos = self.authenticated_user.get_repos()
         LOGGER.debug('Personal repos retrieved!')
 
         return repos
 
-    @staticmethod
-    def get_all_user_repos():
+    def get_all_user_repos(self):
         """Retrieve repos of all users in the users list provided and return a single list
         including every repo from each user flattened.
         """
         all_user_repos = []
-        for user in USERS:
+        for user in self.users:
             formatted_user_name = user.strip()
-            user_repos = GITHUB_LOGIN.get_user(formatted_user_name).get_repos()
+            user_repos = self.github_instance.get_user(formatted_user_name).get_repos()
             all_user_repos.append(user_repos)
             LOGGER.debug(f'{formatted_user_name} repos retrieved!')
 
@@ -147,15 +168,14 @@ class GithubArchive:
 
         return flattened_user_repos_list
 
-    @staticmethod
-    def get_all_org_repos():
+    def get_all_org_repos(self):
         """Retrieve repos of all orgs in the orgs list provided and return a single list
         including every repo from each org flattened.
         """
         all_org_repos = []
-        for org in ORGS:
+        for org in self.orgs:
             formatted_org_name = org.strip()
-            org_repos = GITHUB_LOGIN.get_organization(formatted_org_name).get_repos()
+            org_repos = self.github_instance.get_organization(formatted_org_name).get_repos()
             all_org_repos.append(org_repos)
             LOGGER.debug(f'{formatted_org_name} repos retrieved!')
 
@@ -163,19 +183,26 @@ class GithubArchive:
 
         return flattened_org_repos_list
 
-    @staticmethod
-    def get_gists():
+    def get_all_gists(self):
         """Retrieve all gists of the authenticated user."""
-        gists = AUTHENTICATED_GITHUB_USER.get_gists()
-        LOGGER.debug('User gists retrieved!')
+        all_user_gists = []
+        for user in self.gists:
+            formatted_user_name = user.strip()
+            user_gists = self.github_instance.get_user(formatted_user_name).get_gists()
+            all_user_gists.append(user_gists)
+            LOGGER.debug(f'{formatted_user_name} gists retrieved!')
 
-        return gists
+        flattened_user_repos_list = [gist for user_gists in all_user_gists for gist in user_gists]
 
-    @staticmethod
-    def iterate_repos_to_archive(num_of_threads, repos, context, operation):
+        return flattened_user_repos_list
+
+    def iterate_repos_to_archive(self, repos, context, operation):
         """Iterate over each repository and start a thread if it can be archived."""
+        thread_limiter = BoundedSemaphore(self.threads)
         thread_list = []
+
         for repo in repos:
+            repo_owner_username = repo.owner.login.lower()
             # We check the owner name here to ensure that we only iterate
             # through the user's personal repos which will exclude orgs.
             #
@@ -184,13 +211,12 @@ class GithubArchive:
             #
             # Instead, the user can pass the "--clone_orgs" or "--pull_orgs"
             # flags to allow for more granular control over which repos they get.
-            if repo.owner.name != AUTHENTICATED_GITHUB_USER.name and context == PERSONAL_CONTEXT:
+            if self.token and repo_owner_username != self.authenticated_username and context == PERSONAL_CONTEXT:
                 continue
             else:
-                thread_limiter = BoundedSemaphore(num_of_threads)
-                repo_path = os.path.join(GITHUB_ARCHIVE_LOCATION, 'repos', repo.owner.login, repo.name)
+                repo_path = os.path.join(self.location, 'repos', repo_owner_username, repo.name)
                 repo_thread = Thread(
-                    target=GithubArchive.archive_repo,
+                    target=self.archive_repo,
                     args=(
                         thread_limiter,
                         repo,
@@ -203,15 +229,15 @@ class GithubArchive:
         for thread in thread_list:
             thread.join()
 
-    @staticmethod
-    def iterate_gists_to_archive(num_of_threads, gists, operation):
+    def iterate_gists_to_archive(self, gists, operation):
         """Iterate over each gist and start a thread if it can be archived."""
-        thread_limiter = BoundedSemaphore(num_of_threads)
+        thread_limiter = BoundedSemaphore(self.threads)
         thread_list = []
+
         for gist in gists:
-            gist_path = os.path.join(GITHUB_ARCHIVE_LOCATION, 'gists', gist.id)
+            gist_path = os.path.join(self.location, 'gists', gist.id)
             gist_thread = Thread(
-                target=GithubArchive.archive_gist,
+                target=self.archive_gist,
                 args=(
                     thread_limiter,
                     gist,
@@ -224,16 +250,22 @@ class GithubArchive:
         for thread in thread_list:
             thread.join()
 
-    @staticmethod
-    def archive_repo(thread_limiter, repo, repo_path, operation):
+    def view_repos(self, repos):
+        """View a list of repos that will be cloned/pulled."""
+        for repo in repos:
+            repo_name = f'{repo.owner.login}/{repo.name}'
+            LOGGER.info(repo_name)
+
+    def view_gists(self, gists):
+        """View a list of gists that will be cloned/pulled."""
+        for gist in gists:
+            gist_id = f'{gist.owner.login}/{gist.id}'
+            LOGGER.info(gist_id)
+
+    def archive_repo(self, thread_limiter, repo, repo_path, operation):
         """Clone and pull repos based on the operation passed"""
         if os.path.exists(repo_path) and operation == CLONE_OPERATION:
-            # TODO: There is a bug here if a repo times out or has another error but the folder got created
-            # where the repo won't finish getting cloned and therefore can't reliably get pulled in the future.
-            # Look into a better way to assert this was successful before skipping.
-            # TODO: Move the debug line into the exception block and delete the failed folder so that on a future
-            # run, the app will attempt to re-clone the project
-            LOGGER.debug(f'Repo: {repo.name} already cloned, skipping clone operation.')
+            pass
         else:
             commands = {
                 CLONE_OPERATION: f'git clone {repo.ssh_url} {repo_path}',
@@ -245,31 +277,29 @@ class GithubArchive:
                 thread_limiter.acquire()
                 subprocess.run(
                     git_command,
+                    stdout=subprocess.DEVNULL,
                     stdin=subprocess.DEVNULL,
                     stderr=subprocess.DEVNULL,
                     shell=True,
                     check=True,
-                    timeout=GIT_TIMEOUT,
+                    timeout=self.timeout,
                 )
-                # TODO: If the response of pulling is `Already up to date.`, skip outputting to the logger
                 LOGGER.info(f'Repo: {repo.name} {operation} success!')
             except subprocess.TimeoutExpired:
                 LOGGER.error(f'Git operation timed out archiving {repo.name}.')
+                if os.path.exists(repo_path):
+                    shutil.rmtree(repo_path)
             except subprocess.CalledProcessError as error:
                 LOGGER.error(f'Failed to {operation} {repo.name}\n{error}')
+                if os.path.exists(repo_path):
+                    shutil.rmtree(repo_path)
             finally:
                 thread_limiter.release()
 
-    @staticmethod
-    def archive_gist(thread_limiter, gist, gist_path, operation):
+    def archive_gist(self, thread_limiter, gist, gist_path, operation):
         """Clone and pull gists based on the operation passed"""
         if os.path.exists(gist_path) and operation == CLONE_OPERATION:
-            # TODO: There is a bug here if a repo times out or has another error but the folder got created
-            # where the repo won't finish getting cloned and therefore can't reliably get pulled in the future.
-            # Look into a better way to assert this was successful before skipping.
-            # TODO: Move the debug line into the exception block and delete the failed folder so that on a future
-            # run, the app will attempt to re-clone the project
-            LOGGER.debug(f'Gist: {gist.id} already cloned, skipping clone operation.')
+            pass
         else:
             commands = {
                 CLONE_OPERATION: f'git clone {gist.html_url} {gist_path}',
@@ -281,17 +311,21 @@ class GithubArchive:
                 thread_limiter.acquire()
                 subprocess.run(
                     git_command,
+                    stdout=subprocess.DEVNULL,
                     stdin=subprocess.DEVNULL,
                     stderr=subprocess.DEVNULL,
                     shell=True,
                     check=True,
-                    timeout=GIT_TIMEOUT,
+                    timeout=self.timeout,
                 )
-                # TODO: If the response of pulling is `Already up to date.`, skip outputting to the logger
                 LOGGER.info(f'Gist: {gist.id} {operation} success!')
             except subprocess.TimeoutExpired:
                 LOGGER.error(f'Git operation timed out archiving {gist.id}.')
+                if os.path.exists(gist_path):
+                    shutil.rmtree(gist_path)
             except subprocess.CalledProcessError as error:
                 LOGGER.error(f'Failed to {operation} {gist.id}\n{error}')
+                if os.path.exists(gist_path):
+                    shutil.rmtree(gist_path)
             finally:
                 thread_limiter.release()
diff --git a/github_archive/cli.py b/github_archive/cli.py
index f4eb87f..7272c4a 100644
--- a/github_archive/cli.py
+++ b/github_archive/cli.py
@@ -1,107 +1,116 @@
 import argparse
 
 from github_archive import GithubArchive
-from github_archive.archive import DEFAULT_NUM_THREADS
+from github_archive.constants import DEFAULT_LOCATION, DEFAULT_NUM_THREADS, DEFAULT_TIMEOUT
 
 
-class CLI:
+class GithubArchiveCli:
     def __init__(self):
-        """Setup the CLI arguments"""
         parser = argparse.ArgumentParser(
             description=(
-                'A powerful script to concurrently clone your entire GitHub instance or save it as an archive.'
+                'A powerful tool to concurrently clone or pull user and org repos and gists to create a GitHub archive.'
             )
         )
         parser.add_argument(
-            '-pc',
-            '--personal_clone',
+            '-v',
+            '--view',
             action='store_true',
             required=False,
             default=False,
-            help='Clone personal repos.',
+            help='Pass this flag to view git assets (dry run).',
         )
         parser.add_argument(
-            '-pp',
-            '--personal_pull',
+            '-c',
+            '--clone',
             action='store_true',
             required=False,
             default=False,
-            help='Pull personal repos',
+            help='Pass this flag to clone git assets.',
         )
         parser.add_argument(
-            '-uc',
-            '--users_clone',
+            '-p',
+            '--pull',
             action='store_true',
             required=False,
             default=False,
-            help='Clone user repos.',
+            help='Pass this flag to pull git assets.',
         )
         parser.add_argument(
-            '-up',
-            '--users_pull',
-            action='store_true',
+            '-u',
+            '--users',
+            type=str,
             required=False,
-            default=False,
-            help='Pull user repos.',
+            default=None,
+            help='Pass a comma separated list of users to get repos for.',
         )
         parser.add_argument(
-            '-gc',
-            '--gists_clone',
-            action='store_true',
+            '-o',
+            '--orgs',
+            type=str,
             required=False,
-            default=False,
-            help='Clone personal gists.',
+            default=None,
+            help='Pass a comma separated list of orgs to get repos for.',
         )
         parser.add_argument(
-            '-gp',
-            '--gists_pull',
-            action='store_true',
+            '-g',
+            '--gists',
+            type=str,
             required=False,
-            default=False,
-            help='Pull personal gists.',
+            default=None,
+            help='Pass a comma separated list of users to get gists for.',
         )
         parser.add_argument(
-            '-oc',
-            '--orgs_clone',
-            action='store_true',
+            '-to',
+            '--timeout',
+            type=int,
             required=False,
-            default=False,
-            help='Clone organization repos.',
+            default=DEFAULT_TIMEOUT,
+            help='The number of seconds before a git operation times out.',
         )
         parser.add_argument(
-            '-op',
-            '--orgs_pull',
-            action='store_true',
+            '-th',
+            '--threads',
+            type=int,
             required=False,
-            default=False,
-            help='Pull organization repos.',
+            default=DEFAULT_NUM_THREADS,
+            help='The number of concurrent threads to run.',
         )
         parser.add_argument(
             '-t',
-            '--threads',
+            '--token',
+            type=str,
             required=False,
-            type=int,
-            default=DEFAULT_NUM_THREADS,
-            help='The number of concurrent threads to run.',
+            default=None,
+            help='Provide your GitHub token to authenticate with the GitHub API and gain access to private repos and gists.',  # noqa
+        )
+        parser.add_argument(
+            '-l',
+            '--location',
+            type=str,  # TODO: Create a custom `path` type here
+            required=False,
+            default=DEFAULT_LOCATION,
+            help='The location where you want your GitHub Archive to be stored.',
         )
         parser.parse_args(namespace=self)
 
-    def _run(self):
-        GithubArchive.run(
-            personal_clone=self.personal_clone,
-            personal_pull=self.personal_pull,
-            users_clone=self.users_clone,
-            users_pull=self.users_pull,
-            gists_clone=self.gists_clone,
-            gists_pull=self.gists_pull,
-            orgs_clone=self.orgs_clone,
-            orgs_pull=self.orgs_pull,
-            num_of_threads=self.threads,
+    def run(self):
+        github_archive = GithubArchive(
+            view=self.view,
+            clone=self.clone,
+            pull=self.pull,
+            users=self.users,
+            orgs=self.orgs,
+            gists=self.gists,
+            timeout=self.timeout,
+            threads=self.threads,
+            token=self.token,
+            location=self.location,
         )
+        github_archive.run()
 
 
 def main():
-    CLI()._run()
+    GithubArchiveCli().run()
 
 
 if __name__ == '__main__':
diff --git a/github_archive/constants.py b/github_archive/constants.py
new file mode 100644
index 0000000..c9e70ae
--- /dev/null
+++ b/github_archive/constants.py
@@ -0,0 +1,5 @@
+import os
+
+DEFAULT_LOCATION = os.path.expanduser('~/github-archive')
+DEFAULT_NUM_THREADS = 10
+DEFAULT_TIMEOUT = 300
diff --git a/github_archive/logger.py b/github_archive/logger.py
index d742b58..e352d98 100644
--- a/github_archive/logger.py
+++ b/github_archive/logger.py
@@ -2,22 +2,24 @@ import logging
 import logging.handlers
 import os
 
-GITHUB_ARCHIVE_LOCATION = os.path.expanduser(os.getenv('GITHUB_ARCHIVE_LOCATION', '~/github-archive'))
-LOG_PATH = os.path.join(GITHUB_ARCHIVE_LOCATION, 'logs')
-LOG_FILE = os.path.join(LOG_PATH, 'github-archive.log')
-LOG_MAX_BYTES = int(os.getenv('GITHUB_ARCHIVE_LOG_MAX_BYTES', 200000))
-LOG_BACKUP_COUNT = int(os.getenv('GITHUB_ARCHIVE_LOG_BACKUP_COUNT', 5))
+# 200kb * 5 files = 1mb of logs
+LOG_MAX_BYTES = 200000  # 200kb
+LOG_BACKUP_COUNT = 5
 
 
 class Logger:
     @staticmethod
-    def _setup_logging(logger):
+    def setup_logging(logger, location):
         """Setup project logging (to console and log file)"""
-        if not os.path.exists(LOG_PATH):
-            os.makedirs(LOG_PATH)
+        log_path = os.path.join(location, 'logs')
+        log_file = os.path.join(log_path, 'github-archive.log')
+
+        if not os.path.exists(log_path):
+            os.makedirs(log_path)
+
         logger.setLevel(logging.INFO)
         handler = logging.handlers.RotatingFileHandler(
-            LOG_FILE,
+            log_file,
             maxBytes=LOG_MAX_BYTES,
             backupCount=LOG_BACKUP_COUNT,
         )
diff --git a/setup.py b/setup.py
index 08dbbc5..6a5e14d 100644
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,7 @@ DEV_REQUIREMENTS = [
 setuptools.setup(
     name='github-archive',
     version='3.1.1',
-    description='A powerful script to concurrently clone your entire GitHub instance or save it as an archive.',  # noqa
+    description='A powerful tool to concurrently clone or pull user and org repos and gists to create a GitHub archive.',  # noqa
     long_description=long_description,
     long_description_content_type="text/markdown",
     url='http://github.com/justintime50/github-archive',
@@ -32,11 +32,11 @@ setuptools.setup(
     ],
     install_requires=REQUIREMENTS,
     extras_require={
-        'dev': DEV_REQUIREMENTS
+        'dev': DEV_REQUIREMENTS,
     },
     entry_points={
         'console_scripts': [
-            'github-archive=github_archive.cli:main'
+            'github-archive=github_archive.cli:main',
         ]
     },
     python_requires='>=3.6',


Test patch:
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index 5b25944..232ab54 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -3,11 +3,14 @@ import pytest
 
 
 @pytest.fixture
-def mock_object():
-    """This can be used for repos and/or gists"""
-    mock_object = mock.MagicMock()
-    mock_object.id = '123'
-    mock_object.name = 'Mock Name'
-    mock_object.owner.name = 'Mock Name'
+def mock_git_asset():
+    """This can be used for repos and/or gists, it contains shared data
+    for either git asset for easier testing.
+    """
+    mock_git_asset = mock.MagicMock()
+    mock_git_asset.id = '123'
+    mock_git_asset.name = 'mock-asset-name'
+    mock_git_asset.owner.name = 'Mock User Name'
+    mock_git_asset.owner.login = 'mock_username'
 
-    return mock_object
+    return mock_git_asset
diff --git a/test/unit/test_archive.py b/test/unit/test_archive.py
index 145270b..accf037 100644
--- a/test/unit/test_archive.py
+++ b/test/unit/test_archive.py
@@ -4,384 +4,423 @@ from threading import BoundedSemaphore
 import mock
 import pytest
 from github_archive import GithubArchive
-from github_archive.archive import CLONE_OPERATION, DEFAULT_NUM_THREADS, ORG_CONTEXT, PERSONAL_CONTEXT, PULL_OPERATION
+from github_archive.archive import CLONE_OPERATION, DEFAULT_NUM_THREADS, PERSONAL_CONTEXT, PULL_OPERATION
 
 GITHUB_TOKEN = '123'
 ORG_LIST = 'org1, org2'
 USER_LIST = 'user1, user2'
 
 
-def test_thread_limiter():
+def mock_thread_limiter():
     thread_limiter = BoundedSemaphore(DEFAULT_NUM_THREADS)
 
     return thread_limiter
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', None)
-@mock.patch('github_archive.archive.LOGGER')
-def test_initialize_project_no_github_token(mock_logger):
-    message = 'GITHUB_TOKEN must be present to run github-archive.'
-    with pytest.raises(ValueError) as error:
-        GithubArchive.initialize_project(False, False, False, False)
-    assert mock_logger.critical.called_with(message)
-    assert message == str(error.value)
+@mock.patch('github_archive.archive.Github.get_user')
+@mock.patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
+@mock.patch('github_archive.archive.GithubArchive.view_repos')
+@mock.patch('github_archive.archive.GithubArchive.get_personal_repos')
+def test_run_token_view(mock_get_personal_repos, mock_view_repos, mock_authed_user_in_users, mock_get_user):
+    github_archive = GithubArchive(
+        token='123',
+        users='justintime50',
+        view=True,
+    )
 
+    github_archive.authenticated_username = 'justintime50'
+    github_archive.run()
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('os.path.exists', return_value=False)
-@mock.patch('os.makedirs')
-def test_initialize_project(mock_make_dirs, mock_dir_exist):
-    GithubArchive.initialize_project(False, False, False, False)
-    assert mock_make_dirs.call_count == 2
+    mock_get_personal_repos.assert_called_once()
+    mock_view_repos.assert_called_once()
+    assert github_archive.users == []  # Assert the authed user gets removed from list
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.LOGGER')
-def test_initialize_project_missing_org_list(mock_logger):
-    message = 'GITHUB_ARCHIVE_ORGS must be present when passing org flags.'
-    with pytest.raises(ValueError) as error:
-        GithubArchive.initialize_project(False, False, True, True)
-    assert mock_logger.critical.called_with(message)
-    assert message == str(error.value)
+@mock.patch('github_archive.archive.Github.get_user')
+@mock.patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
+@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
+@mock.patch('github_archive.archive.GithubArchive.get_personal_repos')
+def test_run_token_clone(
+    mock_get_personal_repos, mock_iterate_repos_to_archive, mock_authed_user_in_users, mock_get_user
+):
+    github_archive = GithubArchive(
+        token='123',
+        users='justintime50',
+        clone=True,
+    )
 
+    github_archive.authenticated_username = 'justintime50'
+    github_archive.run()
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.LOGGER')
-def test_initialize_project_missing_user_list(mock_logger):
-    message = 'GITHUB_ARCHIVE_USERS must be present when passing user flags.'
-    with pytest.raises(ValueError) as error:
-        GithubArchive.initialize_project(True, True, False, False)
-    assert mock_logger.critical.called_with(message)
-    assert message == str(error.value)
+    mock_get_personal_repos.assert_called_once()
+    mock_iterate_repos_to_archive.assert_called_once()
+    assert github_archive.users == []  # Assert the authed user gets removed from list
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.GithubArchive.get_personal_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
+@mock.patch('github_archive.archive.Github.get_user')
+@mock.patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
 @mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_personal_clone_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_repos):
-    GithubArchive.run(
-        personal_clone=True,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
+@mock.patch('github_archive.archive.GithubArchive.get_personal_repos')
+def test_run_token_pull(
+    mock_get_personal_repos, mock_iterate_repos_to_archive, mock_authed_user_in_users, mock_get_user
+):
+    github_archive = GithubArchive(
+        token='123',
+        users='justintime50',
+        pull=True,
     )
-    mock_get_repos.assert_called_once()
-    mock_iterate_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
 
+    github_archive.authenticated_username = 'justintime50'
+    github_archive.run()
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.GithubArchive.get_personal_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_personal_pull_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=True,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-    )
-    mock_get_repos.assert_called_once()
-    mock_iterate_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+    mock_get_personal_repos.assert_called_once()
+    mock_iterate_repos_to_archive.assert_called_once()
+    assert github_archive.users == []  # Assert the authed user gets removed from list
 
 
-@mock.patch('github_archive.archive.USER_LIST', USER_LIST)
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+@mock.patch('github_archive.archive.GithubArchive.view_repos')
 @mock.patch('github_archive.archive.GithubArchive.get_all_user_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_user_clone_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=True,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-    )
-    mock_get_repos.assert_called_once()
-    mock_iterate_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+def test_run_users_view(mock_get_all_user_repos, mock_view_repos):
+    GithubArchive(
+        users='justintime50',
+        view=True,
+    ).run()
 
+    mock_get_all_user_repos.assert_called_once()
+    mock_view_repos.assert_called_once()
 
-@mock.patch('github_archive.archive.USER_LIST', USER_LIST)
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+
+@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
 @mock.patch('github_archive.archive.GithubArchive.get_all_user_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
+def test_run_users_clone(mock_get_all_user_repos, mock_iterate_repos_to_archive):
+    GithubArchive(
+        users='justintime50',
+        clone=True,
+    ).run()
+
+    mock_get_all_user_repos.assert_called_once()
+    mock_iterate_repos_to_archive.assert_called_once()
+
+
 @mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_user_pull_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=True,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-    )
-    mock_get_repos.assert_called_once()
-    mock_iterate_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+@mock.patch('github_archive.archive.GithubArchive.get_all_user_repos')
+def test_run_users_pull(mock_get_all_user_repos, mock_iterate_repos_to_archive):
+    GithubArchive(
+        users='justintime50',
+        pull=True,
+    ).run()
+
+    mock_get_all_user_repos.assert_called_once()
+    mock_iterate_repos_to_archive.assert_called_once()
 
 
-@mock.patch('github_archive.archive.ORG_LIST', ORG_LIST)
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+@mock.patch('github_archive.archive.GithubArchive.view_repos')
 @mock.patch('github_archive.archive.GithubArchive.get_all_org_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_orgs_clone_true(mock_logger, mock_iterate_gists, mock_get_all_org_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=True,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-    )
+def test_run_orgs_view(mock_get_all_org_repos, mock_view_repos):
+    GithubArchive(
+        orgs='org1',
+        view=True,
+    ).run()
+
     mock_get_all_org_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+    mock_view_repos.assert_called_once()
 
 
-@mock.patch('github_archive.archive.ORG_LIST', ORG_LIST)
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
 @mock.patch('github_archive.archive.GithubArchive.get_all_org_repos')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_orgs_pull_true(mock_logger, mock_iterate_gists, mock_get_all_org_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=True,
-        gists_clone=False,
-        gists_pull=False,
-    )
+def test_run_orgs_clone(mock_get_all_org_repos, mock_iterate_repos_to_archive):
+    GithubArchive(
+        orgs='org1',
+        clone=True,
+    ).run()
+
     mock_get_all_org_repos.assert_called_once()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+    mock_iterate_repos_to_archive.assert_called_once()
 
 
-@mock.patch('github_archive.archive.ORG_LIST', ORG_LIST)
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
 @mock.patch('github_archive.archive.GithubArchive.get_all_org_repos')
+def test_run_orgs_pull(mock_get_all_org_repos, mock_iterate_repos_to_archive):
+    GithubArchive(
+        orgs='org1',
+        pull=True,
+    ).run()
+
+    mock_get_all_org_repos.assert_called_once()
+    mock_iterate_repos_to_archive.assert_called_once()
+
+
+@mock.patch('github_archive.archive.GithubArchive.view_gists')
+@mock.patch('github_archive.archive.GithubArchive.get_all_gists')
+def test_run_gists_view(get_all_gists, mock_view_gists):
+    GithubArchive(
+        gists='justintime50',
+        view=True,
+    ).run()
+
+    get_all_gists.assert_called_once()
+    mock_view_gists.assert_called_once()
+
+
 @mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
-@mock.patch('github_archive.archive.LOGGER')
-def test_run_orgs_list_without_flag(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_all_org_repos):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=False,
-    )
-    mock_get_all_org_repos.assert_not_called()
-    mock_iterate_repos.assert_not_called()
-    mock_iterate_gists.assert_not_called()
-    mock_logger.info.call_count == 3
+@mock.patch('github_archive.archive.GithubArchive.get_all_gists')
+def test_run_gists_clone(get_all_gists, mock_iterate_gists_to_archive):
+    GithubArchive(
+        gists='org1',
+        clone=True,
+    ).run()
+
+    get_all_gists.assert_called_once()
+    mock_iterate_gists_to_archive.assert_called_once()
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.GithubArchive.get_gists')
 @mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
+@mock.patch('github_archive.archive.GithubArchive.get_all_gists')
+def test_run_gists_pull(get_all_gists, mock_iterate_gists_to_archive):
+    GithubArchive(
+        gists='org1',
+        pull=True,
+    ).run()
+
+    get_all_gists.assert_called_once()
+    mock_iterate_gists_to_archive.assert_called_once()
+
+
+@mock.patch('os.path.exists', return_value=False)
+@mock.patch('os.makedirs')
+def test_initialize_project(mock_make_dirs, mock_dir_exist):
+    GithubArchive().initialize_project()
+
+    assert mock_make_dirs.call_count == 2
+
+
 @mock.patch('github_archive.archive.LOGGER')
-def test_run_gists_clone_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_gists):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=True,
-        gists_pull=False,
-    )
-    mock_get_gists.assert_called_once()
-    mock_iterate_repos.assert_not_called()
-    mock_iterate_gists.assert_called_once()
-    mock_logger.info.call_count == 3
+def test_initialize_project_missing_list(mock_logger):
+    # TODO: Is it possible to test all variations easily in one test?
+    # Parametrize doesn't work great because we can't easily swap the param name being used
+    message = 'A git operation must be specified when a list of users or orgs is provided.'
+    with pytest.raises(ValueError) as error:
+        GithubArchive(users='justintime50').initialize_project()
+
+    mock_logger.critical.assert_called_with(message)
+    assert message == str(error.value)
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
-@mock.patch('github_archive.archive.GithubArchive.get_gists')
-@mock.patch('github_archive.archive.GithubArchive.iterate_gists_to_archive')
-@mock.patch('github_archive.archive.GithubArchive.iterate_repos_to_archive')
 @mock.patch('github_archive.archive.LOGGER')
-def test_run_gists_pull_true(mock_logger, mock_iterate_repos, mock_iterate_gists, mock_get_gists):
-    GithubArchive.run(
-        personal_clone=False,
-        personal_pull=False,
-        users_clone=False,
-        users_pull=False,
-        orgs_clone=False,
-        orgs_pull=False,
-        gists_clone=False,
-        gists_pull=True,
-    )
-    mock_get_gists.assert_called_once()
-    mock_iterate_repos.assert_not_called()
-    mock_iterate_gists.assert_called_once()
-    mock_logger.info.call_count == 3
+def test_initialize_project_missing_operation(mock_logger):
+    # TODO: Is it possible to test all variations easily in one test?
+    # Parametrize doesn't work great because we can't easily swap the param name being used
+    message = 'A list must be provided when a git operation is specified.'
+    with pytest.raises(ValueError) as error:
+        GithubArchive(
+            clone=True,
+        ).initialize_project()
+
+    mock_logger.critical.assert_called_with(message)
+    assert message == str(error.value)
 
 
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER.get_repos')
-def test_get_personal_repos(mock_get_repos):
-    GithubArchive.get_personal_repos()
-    mock_get_repos.assert_called_once()
+@mock.patch('github_archive.archive.Github.get_user')
+def test_authenticated_user_in_users(mock_get_user):
+    authenticated_user_in_users = GithubArchive(
+        token='123',
+        users='unauthenticated_user',
+    ).authenticated_user_in_users()
+
+    assert authenticated_user_in_users is False
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
 @mock.patch('github_archive.archive.Github.get_user')
-def test_get_all_user_repos(get_user):
-    GithubArchive.get_all_user_repos()
-    get_user.assert_called_once()
+def test_get_personal_repos(mock_get_user):
+    GithubArchive(
+        token='123',
+        users='justintime50',
+    ).get_personal_repos()
+
+    mock_get_user.assert_called_once()
 
 
-@mock.patch('github_archive.archive.GITHUB_TOKEN', GITHUB_TOKEN)
+@mock.patch('github_archive.archive.Github')
+@mock.patch('github_archive.archive.Github.get_repos')
+@mock.patch('github_archive.archive.Github.get_user')
+def test_get_all_user_repos(mock_get_user, mock_get_repos, mock_github_instance):
+    users = 'justintime50,user2'
+    GithubArchive(
+        users=users,
+    ).get_all_user_repos()
+
+    mock_get_user.call_count == len(users)
+    mock_get_repos.call_count == len(users)
+    # TODO: Assert the list returned
+
+
+@mock.patch('github_archive.archive.Github')
+@mock.patch('github_archive.archive.Github.get_repos')
 @mock.patch('github_archive.archive.Github.get_organization')
-def test_get_all_org_repos(mock_get_organization):
-    GithubArchive.get_all_org_repos()
-    mock_get_organization.assert_called_once()
+def test_get_all_org_repos(mock_get_org, mock_get_repos, mock_github_instance):
+    orgs = 'org1,org2'
+    GithubArchive(
+        orgs=orgs,
+    ).get_all_org_repos()
 
+    mock_get_org.call_count == len(orgs)
+    mock_get_repos.call_count == len(orgs)
+    # TODO: Assert the list returned
 
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER.get_gists')
-def test_get_gists(mock_get_gists):
-    GithubArchive.get_gists()
-    mock_get_gists.assert_called_once()
 
+@mock.patch('github_archive.archive.Github')
+@mock.patch('github_archive.archive.Github.get_gists')
+@mock.patch('github_archive.archive.Github.get_user')
+def test_get_get_all_gists(mock_get_user, mock_get_gists, mock_github_instance):
+    gists = 'justintime50,user2'
+    GithubArchive(
+        gists=gists,
+    ).get_all_gists()
 
-@mock.patch('github_archive.archive.GithubArchive.archive_repo')
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER')
-def test_iterate_repos_matching_owner_name(mock_user, mock_archive_repo, mock_object):
-    mock_user.name = 'Mock Name'
-    repos = [mock_object]
-    GithubArchive.iterate_repos_to_archive(DEFAULT_NUM_THREADS, repos, PERSONAL_CONTEXT, CLONE_OPERATION)
-    mock_archive_repo.assert_called()
+    mock_get_user.call_count == len(gists)
+    mock_get_gists.call_count == len(gists)
+    # TODO: Assert the list returned
 
 
+@mock.patch('github_archive.archive.Github')
 @mock.patch('github_archive.archive.GithubArchive.archive_repo')
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER')
-def test_iterate_repos_not_matching_owner_name(mock_user, mock_archive_repo, mock_object):
-    mock_user.name = 'Mock Name Does Not Match'
-    repos = [mock_object]
-    GithubArchive.iterate_repos_to_archive(DEFAULT_NUM_THREADS, repos, PERSONAL_CONTEXT, CLONE_OPERATION)
-    mock_archive_repo.assert_not_called()
+def test_iterate_repos_not_matching_authed_username(mock_archive_repo, mock_github_instance, mock_git_asset):
+    repos = [mock_git_asset]
+    GithubArchive(
+        users='mock_username',
+    ).iterate_repos_to_archive(repos, PERSONAL_CONTEXT, CLONE_OPERATION)
 
+    mock_archive_repo.assert_called()
 
+
+@mock.patch('github_archive.archive.Github')
 @mock.patch('github_archive.archive.GithubArchive.archive_repo')
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER')
-def test_iterate_org_repos_success(mock_user, mock_archive_repo, mock_object):
-    mock_user.name = 'Mock Name'
-    repos = [mock_object]
-    GithubArchive.iterate_repos_to_archive(DEFAULT_NUM_THREADS, repos, ORG_CONTEXT, CLONE_OPERATION)
-    mock_archive_repo.assert_called()
+def test_iterate_repos_matching_authed_username(mock_archive_repo, mock_github_instance, mock_git_asset):
+    repos = [mock_git_asset]
+    GithubArchive(
+        token='123',
+        users='mock_username',
+    ).iterate_repos_to_archive(repos, PERSONAL_CONTEXT, CLONE_OPERATION)
+
+    mock_archive_repo.assert_not_called()
 
 
+@mock.patch('github_archive.archive.Github')
 @mock.patch('github_archive.archive.GithubArchive.archive_gist')
-@mock.patch('github_archive.archive.AUTHENTICATED_GITHUB_USER')
-def test_iterate_gists_success(mock_user, mock_archive_gist, mock_object):
-    gists = [mock_object]
-    GithubArchive.iterate_gists_to_archive(DEFAULT_NUM_THREADS, gists, CLONE_OPERATION)
+def test_iterate_gists(mock_archive_gist, mock_github_instance, mock_git_asset):
+    gists = [mock_git_asset]
+    GithubArchive(
+        gists='mock_username',
+    ).iterate_gists_to_archive(gists, CLONE_OPERATION)
+
     mock_archive_gist.assert_called()
 
 
+@mock.patch('github_archive.archive.LOGGER')
+def test_view_repos(mock_logger, mock_git_asset):
+    repos = [mock_git_asset]
+    GithubArchive().view_repos(repos)
+
+    mock_logger.info.assert_called_with('mock_username/mock-asset-name')
+
+
+@mock.patch('github_archive.archive.LOGGER')
+def test_view_gists(mock_logger, mock_git_asset):
+    gists = [mock_git_asset]
+    GithubArchive().view_gists(gists)
+
+    mock_logger.info.assert_called_with('mock_username/123')
+
+
 @mock.patch('subprocess.run')
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_repo_success(mock_logger, mock_subprocess, mock_object):
+def test_archive_repo_success(mock_logger, mock_subprocess, mock_git_asset):
+    # TODO: Mock the subprocess better to ensure it's doing what it should
     operation = CLONE_OPERATION
-    message = f'Repo: {mock_object.name} {operation} success!'
-    GithubArchive.archive_repo(test_thread_limiter(), mock_object, 'mock/path', operation)
+    message = f'Repo: {mock_git_asset.name} {operation} success!'
+    GithubArchive().archive_repo(mock_thread_limiter(), mock_git_asset, 'mock/path', operation)
+
+    mock_subprocess.assert_called()
     mock_logger.info.assert_called_once_with(message)
 
 
 @mock.patch('os.path.exists', return_value=True)
 @mock.patch('subprocess.run')
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_repo_clone_exists(mock_logger, mock_subprocess, mock_path_exists, mock_object):
+def test_archive_repo_clone_exists(mock_logger, mock_subprocess, mock_git_asset):
     operation = CLONE_OPERATION
-    message = f'Repo: {mock_object.name} already cloned, skipping clone operation.'
-    GithubArchive.archive_repo(test_thread_limiter(), mock_object, 'mock/path', operation)
-    mock_logger.debug.assert_called_once_with(message)
+    GithubArchive().archive_repo(mock_thread_limiter(), mock_git_asset, 'assets', operation)
 
+    mock_subprocess.assert_not_called()
 
+
+@mock.patch('shutil.rmtree')
 @mock.patch('subprocess.run', side_effect=subprocess.TimeoutExpired(cmd=subprocess.run, timeout=0.1))
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_repo_timeout_exception(mock_logger, mock_subprocess, mock_object):
+def test_archive_repo_timeout_exception(mock_logger, mock_subprocess, mock_remove_dir, mock_git_asset):
     operation = CLONE_OPERATION
-    message = f'Git operation timed out archiving {mock_object.name}.'
-    GithubArchive.archive_repo(test_thread_limiter(), mock_object, 'mock/path', operation)
+    message = f'Git operation timed out archiving {mock_git_asset.name}.'
+    GithubArchive().archive_repo(mock_thread_limiter(), mock_git_asset, 'mock/path', operation)
+
     mock_logger.error.assert_called_with(message)
+    # TODO: This is difficult to mock because it must not exist and then later exist in the same function
+    # mock_remove_dir.assert_called_once_with('mock/path')
 
 
+@mock.patch('shutil.rmtree')
 @mock.patch('subprocess.run', side_effect=subprocess.CalledProcessError(returncode=1, cmd=subprocess.run))
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_repo_called_process_error(mock_logger, mock_subprocess, mock_object):
+def test_archive_repo_called_process_error(mock_logger, mock_subprocess, mock_remove_dir, mock_git_asset):
     operation = PULL_OPERATION
-    GithubArchive.archive_repo(test_thread_limiter(), mock_object, 'mock/path', operation)
+    GithubArchive().archive_repo(mock_thread_limiter(), mock_git_asset, 'mock/path', operation)
+
     mock_logger.error.assert_called()
+    # TODO: This is difficult to mock because it must not exist and then later exist in the same function
+    # mock_remove_dir.assert_called_once_with('mock/path')
 
 
 @mock.patch('subprocess.run')
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_gist_success(mock_logger, mock_subprocess, mock_object):
+def test_archive_gist_success(mock_logger, mock_subprocess, mock_git_asset):
+    # TODO: Mock the subprocess better to ensure it's doing what it should
     operation = CLONE_OPERATION
-    message = f'Gist: {mock_object.id} {operation} success!'
-    GithubArchive.archive_gist(test_thread_limiter(), mock_object, 'mock/path', operation)
+    message = f'Gist: {mock_git_asset.id} {operation} success!'
+    GithubArchive().archive_gist(mock_thread_limiter(), mock_git_asset, 'mock/path', operation)
+
+    mock_subprocess.assert_called()
     mock_logger.info.assert_called_once_with(message)
 
 
 @mock.patch('os.path.exists', return_value=True)
 @mock.patch('subprocess.run')
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_gist_clone_exists(mock_logger, mock_subprocess, mock_path_exists, mock_object):
+def test_archive_gist_clone_exists(mock_logger, mock_subprocess, mock_path_exists, mock_git_asset):
     operation = CLONE_OPERATION
-    message = f'Gist: {mock_object.id} already cloned, skipping clone operation.'
-    GithubArchive.archive_gist(test_thread_limiter(), mock_object, 'mock/path', operation)
-    mock_logger.debug.assert_called_once_with(message)
+    GithubArchive().archive_gist(mock_thread_limiter(), mock_git_asset, 'assets', operation)
 
+    mock_subprocess.assert_not_called()
 
+
+@mock.patch('shutil.rmtree')
 @mock.patch('subprocess.run', side_effect=subprocess.TimeoutExpired(cmd=subprocess.run, timeout=0.1))
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_gist_timeout_exception(mock_logger, mock_subprocess, mock_object):
+def test_archive_gist_timeout_exception(mock_logger, mock_subprocess, mock_remove_dir, mock_git_asset):
     operation = CLONE_OPERATION
-    message = f'Git operation timed out archiving {mock_object.id}.'
-    GithubArchive.archive_gist(test_thread_limiter(), mock_object, 'mock/path', operation)
+    message = f'Git operation timed out archiving {mock_git_asset.id}.'
+    GithubArchive().archive_gist(mock_thread_limiter(), mock_git_asset, 'mock/path', operation)
+
     mock_logger.error.assert_called_with(message)
+    # TODO: This is difficult to mock because it must not exist and then later exist in the same function
+    # mock_remove_dir.assert_called_once_with('mock/path')
 
 
+@mock.patch('shutil.rmtree')
 @mock.patch('subprocess.run', side_effect=subprocess.CalledProcessError(returncode=1, cmd=subprocess.run))
 @mock.patch('github_archive.archive.LOGGER')
-def test_archive_gist_called_process_error(mock_logger, mock_subprocess, mock_object):
+def test_archive_gist_called_process_error(mock_logger, mock_subprocess, mock_remove_dir, mock_git_asset):
     operation = PULL_OPERATION
-    GithubArchive.archive_gist(test_thread_limiter(), mock_object, 'mock/path', operation)
+    GithubArchive().archive_gist(mock_thread_limiter(), mock_git_asset, 'assets', operation)
+
     mock_logger.error.assert_called()
+    # TODO: This is difficult to mock because it must not exist and then later exist in the same function
+    # mock_remove_dir.assert_called_once_with('mock/path')
diff --git a/test/unit/test_logger.py b/test/unit/test_logger.py
index bc3f81c..b2850b4 100644
--- a/test/unit/test_logger.py
+++ b/test/unit/test_logger.py
@@ -1,14 +1,17 @@
+import os
+
 import mock
 from github_archive.logger import Logger
 
+LOG_PATH = 'test/mock-dir'
+LOG_FILE = './test/test.log'
+
 
-@mock.patch('github_archive.logger.LOG_PATH', 'test/mock-dir')
-@mock.patch('github_archive.logger.LOG_FILE', './test/test.log')
 @mock.patch('os.makedirs')
 @mock.patch('github_archive.archive.LOGGER')
 def test_setup_logging(mock_logger, mock_make_dirs):
     with mock.patch('builtins.open', mock.mock_open()):
-        Logger._setup_logging(mock_logger)
+        Logger.setup_logging(mock_logger, LOG_PATH)
     mock_make_dirs.assert_called_once()
     mock_logger.setLevel.assert_called()
     mock_logger.addHandler.assert_called()
@@ -17,8 +20,12 @@ def test_setup_logging(mock_logger, mock_make_dirs):
 @mock.patch('os.makedirs')
 @mock.patch('github_archive.archive.LOGGER')
 def test_setup_logging_dir_exists(mock_logger, mock_make_dirs):
+    # TODO: Mock this better so we don't need a gitignored empty folder for testing
+    if not os.path.exists('./logs'):
+        os.mkdir('logs')
+
     with mock.patch('builtins.open', mock.mock_open()):
-        Logger._setup_logging(mock_logger)
+        Logger.setup_logging(mock_logger, './')
     mock_make_dirs.assert_not_called()
     mock_logger.setLevel.assert_called()
     mock_logger.addHandler.assert_called()