Repository: Justintime50/github-archive

Issue:
Bulk forking
Is it possible to do bulk forking of all repos of an account?

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ec0a33..7998cd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## NEXT RELEASE
 
+- Adds a new `--fork` CLI arg which adds support to fork the repos or gists specified via `users`, `orgs`, `stars`, or `gists`
+- Removed various shorthand CLI args to avoid confusion and improved help message output for CLI args (updated docs as well)
 - Namespaces for various functions changed for better project organization. As this project is intended to be used as a CLI tool and not a library, the impact should be minimal
 
 ## 4.5.1 (2022-09-15)
diff --git a/README.md b/README.md
index 0615d2f..4d79e03 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 # GitHub Archive
 
-A powerful tool to concurrently clone or pull user and org repos and gists to create a GitHub archive.
+A powerful tool to concurrently clone, pull, or fork 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)
@@ -13,7 +13,7 @@ A powerful tool to concurrently clone or pull user and org repos and gists to cr
 
 </div>
 
-GitHub Archive is a powerful tool to concurrently clone or pull repositories or gists from GitHub with incredible flexibility. It's the perfect tool for spinning up a new dev environment, keeping a local copy of your GitHub instance, or quickly pulling in projects from your favorite users and organizations.
+GitHub Archive is a powerful tool to concurrently clone, pull, or fork repositories or gists from GitHub with incredible flexibility. It's the perfect tool for spinning up a new dev environment, keeping a local copy of your GitHub instance, or quickly pulling in projects from your favorite users and organizations.
 
 The power of GitHub Archive comes in its configuration. Maybe you only want to clone or pull your personal public repos or maybe you want to go all out and include private repos from you and all organizations you belong to along with your gists. GitHub Archive can do it all.
 
@@ -47,21 +47,17 @@ Options:
     -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.
-    -f, --forks           Pass this flag to include forked git assets.
-    -i INCLUDE, --include INCLUDE
-                            Pass a comma separated list of repos to filter what is included in the Archive.
-    -e EXCLUDE, --exclude EXCLUDE
-                            Pass a comma separated list of repos to filter what is excluded from the Archive.
-    -l LOCATION, --location LOCATION
-                            The location where you want your GitHub Archive to be stored. By default, this is /Users/USERNAME/github-archive
-    -ht, --https          Use HTTPS URLs instead of SSH.
-    -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.
-    --base_url BASE_URL   The base URL of your GitHub instance (useful for enterprise users with custom hostnames).
-    --log_level {error,debug,warning,info,critical}
-                            The log level used for the tool.
+    -f, --fork            Pass this flag to fork git assets.
+    --include INCLUDE     Pass a comma separated list of repos to filter what is included in the Archive.
+    --exclude EXCLUDE     Pass a comma separated list of repos to filter what is excluded from the Archive.
+    --forks               Pass this flag to include forked git assets (when cloning or pulling).
+    --location LOCATION   The location where you want your GitHub Archive to be stored. Default: /Users/USERNAME/github-archive
+    --https               Use HTTPS URLs instead of SSH.
+    --timeout TIMEOUT     The number of seconds before a git operation times out. Default: 300
+    --threads THREADS     The number of concurrent threads to run. Default: 10
+    --base_url BASE_URL   The base URL of your GitHub instance (useful for enterprise users with custom hostnames). Default: https://api.github.com
+    --log_level {error,critical,warning,info,debug}
+                            The log level used for the tool. Default: info
 ```
 
 ### Automating SSH Passphrase Prompt (Recommended)
diff --git a/github_archive/archive.py b/github_archive/archive.py
index 3c3e751..e09ea67 100644
--- a/github_archive/archive.py
+++ b/github_archive/archive.py
@@ -31,6 +31,7 @@ from github_archive.constants import (
 )
 from github_archive.gists import (
     iterate_gists_to_archive,
+    iterate_gists_to_fork,
     view_gists,
 )
 from github_archive.logger import (
@@ -39,6 +40,7 @@ from github_archive.logger import (
 )
 from github_archive.repos import (
     iterate_repos_to_archive,
+    iterate_repos_to_fork,
     view_repos,
 )
 
@@ -54,10 +56,11 @@ class GithubArchive:
         view=False,
         clone=False,
         pull=False,
-        forks=False,
-        location=DEFAULT_LOCATION,
+        fork=False,
         include=None,
         exclude=None,
+        forks=False,
+        location=DEFAULT_LOCATION,
         use_https=False,
         timeout=DEFAULT_TIMEOUT,
         threads=DEFAULT_NUM_THREADS,
@@ -73,10 +76,11 @@ class GithubArchive:
         self.view = view
         self.clone = clone
         self.pull = pull
-        self.forks = forks
-        self.location = location
+        self.fork = fork
         self.include = include.lower().split(',') if include else ''
         self.exclude = exclude.lower().split(',') if exclude else ''
+        self.forks = forks
+        self.location = location
         self.use_https = use_https
         self.timeout = timeout
         self.threads = threads
@@ -112,6 +116,9 @@ class GithubArchive:
             if self.pull:
                 logger.info('# Pulling changes to personal repos...\n')
                 _ = iterate_repos_to_archive(self, personal_repos, PULL_OPERATION)
+            if self.fork:
+                # We can't fork a repo we already have, do nothing
+                pass
 
             # We remove the authenticated user from the list so that we don't double pull their
             # repos for the `users` logic.
@@ -133,6 +140,9 @@ class GithubArchive:
             if self.pull:
                 logger.info('# Pulling changes to user repos...\n')
                 _ = iterate_repos_to_archive(self, user_repos, PULL_OPERATION)
+            if self.fork:
+                logger.info('# Forking starred repos...\n')
+                iterate_repos_to_fork(user_repos)
 
         # Orgs
         if self.orgs:
@@ -150,6 +160,9 @@ class GithubArchive:
             if self.pull:
                 logger.info('# Pulling changes to org repos...\n')
                 _ = iterate_repos_to_archive(self, org_repos, PULL_OPERATION)
+            if self.fork:
+                logger.info('# Forking starred repos...\n')
+                iterate_repos_to_fork(org_repos)
 
         # Stars
         if self.stars:
@@ -167,6 +180,9 @@ class GithubArchive:
             if self.pull:
                 logger.info('# Pulling changes to starred repos...\n')
                 _ = iterate_repos_to_archive(self, starred_repos, PULL_OPERATION)
+            if self.fork:
+                logger.info('# Forking starred repos...\n')
+                iterate_repos_to_fork(starred_repos)
 
         if failed_repo_dirs:
             logger.info('Cleaning up repos...\n')
@@ -189,6 +205,9 @@ class GithubArchive:
             if self.pull:
                 logger.info('# Pulling changes to gists...\n')
                 _ = iterate_gists_to_archive(self, gists, PULL_OPERATION)
+            if self.fork:
+                logger.info('# Forking gists...\n')
+                iterate_gists_to_fork(gists)
 
             if failed_gist_dirs:
                 logger.info('Cleaning up gists...\n')
@@ -211,17 +230,23 @@ class GithubArchive:
             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 or self.stars) and not (self.view or self.clone or self.pull):
+        if (self.users or self.orgs or self.gists or self.stars) and not (
+            self.view or self.clone or self.pull or self.fork
+        ):
             log_and_raise_value_error(
                 logger=logger,
                 message='A git operation must be specified when a list of users or orgs is provided.',
             )
-        elif not (self.users or self.orgs or self.gists or self.stars) and (self.view or self.clone or self.pull):
+        elif not (self.users or self.orgs or self.gists or self.stars) and (
+            self.view or self.clone or self.pull or self.fork
+        ):
             log_and_raise_value_error(
                 logger=logger,
                 message='A list must be provided when a git operation is specified.',
             )
-        elif not (self.users or self.orgs or self.gists or self.stars or self.view or self.clone or self.pull):
+        elif not (
+            self.users or self.orgs or self.gists or self.stars or self.view or self.clone or self.pull or self.fork
+        ):
             log_and_raise_value_error(
                 logger=logger,
                 message='At least one git operation and one list must be provided to run github-archive.',
diff --git a/github_archive/cli.py b/github_archive/cli.py
index 60115d8..636da4d 100644
--- a/github_archive/cli.py
+++ b/github_archive/cli.py
@@ -88,14 +88,13 @@ class GithubArchiveCli:
         )
         parser.add_argument(
             '-f',
-            '--forks',
+            '--fork',
             action='store_true',
             required=False,
             default=False,
-            help='Pass this flag to include forked git assets.',
+            help='Pass this flag to fork git assets.',
         )
         parser.add_argument(
-            '-i',
             '--include',
             type=str,
             required=False,
@@ -103,7 +102,6 @@ class GithubArchiveCli:
             help='Pass a comma separated list of repos to filter what is included in the Archive.',
         )
         parser.add_argument(
-            '-e',
             '--exclude',
             type=str,
             required=False,
@@ -111,17 +109,20 @@ class GithubArchiveCli:
             help='Pass a comma separated list of repos to filter what is excluded from the Archive.',
         )
         parser.add_argument(
-            '-l',
+            '--forks',
+            action='store_true',
+            required=False,
+            default=False,
+            help='Pass this flag to include forked git assets (when cloning or pulling).',
+        )
+        parser.add_argument(
             '--location',
             type=str,
             required=False,
             default=DEFAULT_LOCATION,
-            help=(
-                f'The location where you want your GitHub Archive to be stored. By default, this is: {DEFAULT_LOCATION}'
-            ),
+            help=f'The location where you want your GitHub Archive to be stored. Default: {DEFAULT_LOCATION}',
         )
         parser.add_argument(
-            '-ht',
             '--https',
             action='store_true',
             required=False,
@@ -129,27 +130,28 @@ class GithubArchiveCli:
             help='Use HTTPS URLs instead of SSH.',
         )
         parser.add_argument(
-            '-to',
             '--timeout',
             type=int,
             required=False,
             default=DEFAULT_TIMEOUT,
-            help='The number of seconds before a git operation times out.',
+            help=f'The number of seconds before a git operation times out. Default: {DEFAULT_TIMEOUT}',
         )
         parser.add_argument(
-            '-th',
             '--threads',
             type=int,
             required=False,
             default=DEFAULT_NUM_THREADS,
-            help='The number of concurrent threads to run.',
+            help=f'The number of concurrent threads to run. Default: {DEFAULT_NUM_THREADS}',
         )
         parser.add_argument(
             '--base_url',
             type=str,
             required=False,
             default=DEFAULT_BASE_URL,
-            help='The base URL of your GitHub instance (useful for enterprise users with custom hostnames).',
+            help=(  # noqa
+                'The base URL of your GitHub instance (useful for enterprise users with custom hostnames). Default:'
+                f' {DEFAULT_BASE_URL}'
+            ),
         )
         parser.add_argument(
             '--log_level',
@@ -157,7 +159,7 @@ class GithubArchiveCli:
             required=False,
             default=DEFAULT_LOG_LEVEL,
             choices=set(get_args(LOG_LEVEL_CHOICES)),
-            help='The log level used for the tool.',
+            help=f'The log level used for the tool. Default: {DEFAULT_LOG_LEVEL}',
         )
         parser.parse_args(namespace=self)
 
@@ -171,10 +173,11 @@ class GithubArchiveCli:
             view=self.view,
             clone=self.clone,
             pull=self.pull,
-            forks=self.forks,
-            location=self.location,
+            fork=self.fork,
             include=self.include,
             exclude=self.exclude,
+            forks=self.forks,
+            location=self.location,
             use_https=self.https,
             timeout=self.timeout,
             threads=self.threads,
diff --git a/github_archive/gists.py b/github_archive/gists.py
index 95f1354..0b2543d 100644
--- a/github_archive/gists.py
+++ b/github_archive/gists.py
@@ -62,6 +62,25 @@ def view_gists(gists: List[Gist.Gist]):
         logger.info(gist_id)
 
 
+def iterate_gists_to_fork(gists: List[Gist.Gist]):
+    """Iterates through a list of gists and attempts to fork them."""
+    for gist in gists:
+        _fork_gist(gist)
+
+
+def _fork_gist(gist: Gist.Gist):
+    """Forks a gist to the authenticated user's GitHub instance."""
+    logger = woodchips.get(LOGGER_NAME)
+
+    gist_id = f'{gist.owner.login}/{gist.id}'
+
+    try:
+        gist.create_fork()
+        logger.info(f'{gist_id} forked!')
+    except Exception:
+        logger.warning(f'{gist_id} failed to fork!')
+
+
 def _archive_gist(github_archive: GithubArchive, gist: Gist.Gist, gist_path: str, operation: str) -> Optional[str]:
     """Clone and pull gists based on the operation passed.
 
diff --git a/github_archive/repos.py b/github_archive/repos.py
index 3243232..fd715c6 100644
--- a/github_archive/repos.py
+++ b/github_archive/repos.py
@@ -31,7 +31,8 @@ from github_archive.constants import (
 def iterate_repos_to_archive(
     github_archive: GithubArchive, repos: List[Repository.Repository], operation: str
 ) -> List[Optional[str]]:
-    """Iterate over each repository and start a thread if it can be archived.
+    """Iterate over each repository and start a thread after filtering based on the
+    user input and attempt to archive it.
 
     We ignore repos not in the include or in the exclude list if either are present.
     """
@@ -74,6 +75,25 @@ def view_repos(repos: List[Repository.Repository]):
         logger.info(repo_name)
 
 
+def iterate_repos_to_fork(repos: List[Repository.Repository]):
+    """Iterates through a list of repos and attempts to fork them."""
+    for repo in repos:
+        _fork_repo(repo)
+
+
+def _fork_repo(repo: Repository.Repository):
+    """Forks a repository to the authenticated user's GitHub instance."""
+    logger = woodchips.get(LOGGER_NAME)
+
+    repo_name = f'{repo.owner.login}/{repo.name}'
+
+    try:
+        repo.create_fork()
+        logger.info(f'{repo_name} forked!')
+    except Exception:
+        logger.error(f'{repo_name} failed to fork!')
+
+
 def _archive_repo(
     github_archive: GithubArchive, repo: Repository.Repository, repo_path: str, operation: str
 ) -> Optional[str]:
diff --git a/setup.py b/setup.py
index 673eb09..4975fcd 100644
--- a/setup.py
+++ b/setup.py
@@ -27,7 +27,7 @@ setuptools.setup(
     name='github-archive',
     version='4.5.1',
     description=(
-        'A powerful tool to concurrently clone or pull user and org repos and gists to create a GitHub archive.'
+        'A powerful tool to concurrently clone, pull, or fork user and org repos and gists to create a GitHub archive.'
     ),
     long_description=long_description,
     long_description_content_type="text/markdown",


Test patch:
diff --git a/test/unit/test_archive.py b/test/unit/test_archive.py
index b544b38..f0971a8 100644
--- a/test/unit/test_archive.py
+++ b/test/unit/test_archive.py
@@ -12,64 +12,48 @@ from github_archive.archive import (
 )
 
 
+@pytest.mark.parametrize(
+    'args, patch_function',
+    [
+        (
+            {
+                'token': '123',
+                'users': 'justintime50',
+                'view': True,
+            },
+            'github_archive.archive.view_repos',
+        ),
+        (
+            {
+                'token': '123',
+                'users': 'justintime50',
+                'clone': True,
+            },
+            'github_archive.archive.iterate_repos_to_archive',
+        ),
+        (
+            {
+                'token': '123',
+                'users': 'justintime50',
+                'pull': True,
+            },
+            'github_archive.archive.iterate_repos_to_archive',
+        ),
+    ],
+)
 @patch('github_archive.archive.Github.get_user')
 @patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
-@patch('github_archive.archive.view_repos')
-@patch('github_archive.archive.GithubArchive.get_all_git_assets')
-def test_run_token_view(mock_get_all_git_assets, 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_get_all_git_assets.assert_called_once()
-    mock_view_repos.assert_called_once()
-    assert github_archive.users == []  # Assert the authed user gets removed from list
-
-
-@patch('github_archive.archive.Github.get_user')
-@patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
-@patch('github_archive.archive.iterate_repos_to_archive')
-@patch('github_archive.archive.GithubArchive.get_all_git_assets')
-def test_run_token_clone(
-    mock_get_all_git_assets, 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_get_all_git_assets.assert_called_once()
-    mock_iterate_repos_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), CLONE_OPERATION)
-    assert github_archive.users == []  # Assert the authed user gets removed from list
-
-
-@patch('github_archive.archive.Github.get_user')
-@patch('github_archive.archive.GithubArchive.authenticated_user_in_users', return_value=True)
-@patch('github_archive.archive.iterate_repos_to_archive')
 @patch('github_archive.archive.GithubArchive.get_all_git_assets')
-def test_run_token_pull(
-    mock_get_all_git_assets, mock_iterate_repos_to_archive, mock_authed_user_in_users, mock_get_user
-):
-    github_archive = GithubArchive(
-        token='123',
-        users='justintime50',
-        pull=True,
-    )
+def test_run_with_token(mock_get_all_git_assets, mock_authed_user_in_users, mock_get_user, args, patch_function):
+    """Tests running the tool with different params when a token is specified."""
+    github_archive = GithubArchive(**args)
 
     github_archive.authenticated_username = 'justintime50'
-    github_archive.run()
+    with patch(patch_function) as func:
+        github_archive.run()
 
     mock_get_all_git_assets.assert_called_once()
-    mock_iterate_repos_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), PULL_OPERATION)
+    func.assert_called_once()
     assert github_archive.users == []  # Assert the authed user gets removed from list
 
 
@@ -115,6 +99,20 @@ def test_run_users_pull(mock_get_all_git_assets, mock_iterate_repos_to_archive):
     mock_iterate_repos_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), PULL_OPERATION)
 
 
+@patch('github_archive.archive.iterate_repos_to_fork')
+@patch('github_archive.archive.GithubArchive.get_all_git_assets')
+def test_run_users_fork(mock_get_all_git_assets, mock_iterate_repos_to_fork):
+    github_archive = GithubArchive(
+        users='justintime50',
+        fork=True,
+    )
+
+    github_archive.run()
+
+    mock_get_all_git_assets.assert_called_once()
+    mock_iterate_repos_to_fork.assert_called_once()
+
+
 @patch('github_archive.archive.view_repos')
 @patch('github_archive.archive.GithubArchive.get_all_git_assets')
 def test_run_orgs_view(mock_get_all_git_assets, mock_view_repos):
@@ -157,6 +155,20 @@ def test_run_orgs_pull(mock_get_all_git_assets, mock_iterate_repos_to_archive):
     mock_iterate_repos_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), PULL_OPERATION)
 
 
+@patch('github_archive.archive.iterate_repos_to_fork')
+@patch('github_archive.archive.GithubArchive.get_all_git_assets')
+def test_run_orgs_fork(mock_get_all_git_assets, mock_iterate_repos_to_fork):
+    github_archive = GithubArchive(
+        orgs='org1',
+        fork=True,
+    )
+
+    github_archive.run()
+
+    mock_get_all_git_assets.assert_called_once()
+    mock_iterate_repos_to_fork.assert_called_once()
+
+
 @patch('github_archive.archive.view_gists')
 @patch('github_archive.archive.GithubArchive.get_all_git_assets')
 def test_run_gists_view(mock_get_all_git_assets, mock_view_gists):
@@ -199,6 +211,20 @@ def test_run_gists_pull(mock_get_all_git_assets, mock_iterate_gists_to_archive):
     mock_iterate_gists_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), PULL_OPERATION)
 
 
+@patch('github_archive.archive.iterate_gists_to_fork')
+@patch('github_archive.archive.GithubArchive.get_all_git_assets')
+def test_run_gists_fork(mock_get_all_git_assets, mock_iterate_gists_to_fork):
+    github_archive = GithubArchive(
+        gists='org1',
+        fork=True,
+    )
+
+    github_archive.run()
+
+    mock_get_all_git_assets.assert_called_once()
+    mock_iterate_gists_to_fork.assert_called_once()
+
+
 @patch('github_archive.archive.view_repos')
 @patch('github_archive.archive.GithubArchive.get_all_git_assets')
 def test_run_stars_view(mock_get_all_git_assets, mock_view_repos):
@@ -241,6 +267,20 @@ def test_run_stars_pull(mock_get_all_git_assets, mock_iterate_repos_to_archive):
     mock_iterate_repos_to_archive.assert_called_once_with(github_archive, mock_get_all_git_assets(), PULL_OPERATION)
 
 
+@patch('github_archive.archive.iterate_repos_to_fork')
+@patch('github_archive.archive.GithubArchive.get_all_git_assets')
+def test_run_stars_fork(mock_get_all_git_assets, mock_iterate_repos_to_fork):
+    github_archive = GithubArchive(
+        stars='justintime50',
+        fork=True,
+    )
+
+    github_archive.run()
+
+    mock_get_all_git_assets.assert_called_once()
+    mock_iterate_repos_to_fork.assert_called_once()
+
+
 @patch('github_archive.archive.setup_logger')
 @patch('os.path.exists', return_value=False)
 @patch('os.makedirs')
diff --git a/test/unit/test_gists.py b/test/unit/test_gists.py
index 1ca63d8..effae9a 100644
--- a/test/unit/test_gists.py
+++ b/test/unit/test_gists.py
@@ -1,5 +1,10 @@
 import subprocess
-from unittest.mock import patch
+from unittest.mock import (
+    MagicMock,
+    patch,
+)
+
+from github import Gist
 
 from github_archive import GithubArchive
 from github_archive.archive import (
@@ -8,7 +13,9 @@ from github_archive.archive import (
 )
 from github_archive.gists import (
     _archive_gist,
+    _fork_gist,
     iterate_gists_to_archive,
+    iterate_gists_to_fork,
     view_gists,
 )
 
@@ -77,3 +84,33 @@ def test_view_gists(mock_logger, mock_git_asset):
     view_gists(gists)
 
     mock_logger.assert_called_with('mock_username/123')
+
+
+@patch('github_archive.gists._fork_gist')
+def test_iterate_gists_to_fork(mock_fork_gist):
+    gist = MagicMock(spec=Gist.Gist)
+    iterate_gists_to_fork([gist])
+
+    mock_fork_gist.assert_called_once()
+
+
+@patch('logging.Logger.info')
+@patch('github.Gist.Gist.create_fork')
+def test_fork_gist_success(mock_create_fork, mock_logger):
+    gist = MagicMock(spec=Gist.Gist)
+    gist.create_fork = mock_create_fork
+    _fork_gist(gist)
+
+    mock_create_fork.assert_called_once()
+    mock_logger.assert_called_once()
+
+
+@patch('logging.Logger.warning')
+@patch('github.Gist.Gist.create_fork', side_effect=Exception())
+def test_fork_gist_failure(mock_create_fork, mock_logger):
+    gist = MagicMock(spec=Gist.Gist)
+    gist.create_fork = mock_create_fork
+    _fork_gist(gist)
+
+    mock_create_fork.assert_called_once()
+    mock_logger.assert_called_once()
diff --git a/test/unit/test_repos.py b/test/unit/test_repos.py
index e8ddf4a..c15878b 100644
--- a/test/unit/test_repos.py
+++ b/test/unit/test_repos.py
@@ -1,5 +1,10 @@
 import subprocess
-from unittest.mock import patch
+from unittest.mock import (
+    MagicMock,
+    patch,
+)
+
+from github import Repository
 
 from github_archive import GithubArchive
 from github_archive.archive import (
@@ -8,7 +13,9 @@ from github_archive.archive import (
 )
 from github_archive.repos import (
     _archive_repo,
+    _fork_repo,
     iterate_repos_to_archive,
+    iterate_repos_to_fork,
     view_repos,
 )
 
@@ -137,3 +144,33 @@ def test_archive_repo_called_process_error(mock_logger, mock_subprocess, mock_gi
     _archive_repo(github_archive, mock_git_asset, 'github_archive', operation)
 
     mock_logger.assert_called_once()
+
+
+@patch('github_archive.repos._fork_repo')
+def test_iterate_repos_to_fork(mock_fork_repo):
+    repo = MagicMock(spec=Repository.Repository)
+    iterate_repos_to_fork([repo])
+
+    mock_fork_repo.assert_called_once()
+
+
+@patch('logging.Logger.info')
+@patch('github.Repository.Repository.create_fork')
+def test_fork_repo_success(mock_create_fork, mock_logger):
+    repo = MagicMock(spec=Repository.Repository)
+    repo.create_fork = mock_create_fork
+    _fork_repo(repo)
+
+    mock_create_fork.assert_called_once()
+    mock_logger.assert_called_once()
+
+
+@patch('logging.Logger.error')
+@patch('github.Repository.Repository.create_fork', side_effect=Exception())
+def test_fork_repo_failure(mock_create_fork, mock_logger):
+    repo = MagicMock(spec=Repository.Repository)
+    repo.create_fork = mock_create_fork
+    _fork_repo(repo)
+
+    mock_create_fork.assert_called_once()
+    mock_logger.assert_called_once()