Repository: VirtusLab/git-machete
Pull request #205 (2021-09-02 09:08:21)

Introduce github checkout-pr command
Known bug until now:
when `checkout-pr` detects opened PR on parent branches, it will try to add the chain of branches into definition file and also checkout them locally. then user is asked if he wants to add those branches one by one, and if he answers no in the middle the adding next branch will cause error because `onto` branch does not exists for git-machete 

PawelLipski: Whoops I've just tested locally on a sample PR chain that used to look like that:

![image](https://user-images.githubusercontent.com/3383210/131864305-68572d30-3841-4ede-9add-32c1ee24dcf2.png)

I removed all 3 branches from .git/machete and executed `git machete github checkout-pr 1217` to restore them... unfortunately:
1. branch `PR_1217` has been checked out, I'd rather expect the corresponding branch `chore/GMV3-1494-jitpack-to-sonatype` to be checked out, this is more practical
2. no branch has been added to .git/machete :/// and I'd rather expect the 3-PR chain to be re-added

Pls take a look!
rifel123: About known bug described at the very top: i see three different scenarios here:
1) do not ask user if he wants to add branches or not, just add them (kind of brutal)
2) if answer to add is `n`, try to add child branches with asking (but then first branch after `n` will be a root branch)
3) if answer to add is `n`, add only head pr branch as root, if it's not managed already

I am looking for your votes/suggestions :)
PawelLipski: I took the liberty to change the PR base to PR #214 (`git machete github retarget-pr` FTW ;) )
PawelLipski: > when checkout-pr detects opened PR on parent branches, it will try to add the chain of branches into definition file and also checkout them locally. then user is asked if he wants to add those branches one by one, and if he answers no in the middle the

Hmmm wait:

```
$ git machete github checkout-pr 205
Added branch testing/github_methods onto develop
Added branch enchance/checkout-pr onto testing/github_methods
Annotating testing/github_methods as PR #214 (rifel123)
Annotating enchance/checkout-pr as PR #205 (rifel123)
Pull request #205 checked out at local branch enchance/checkout-pr
```

`checkout-pr` didn't ask me about anything here 🤔 
rifel123: @PawelLipski `checkout-pr` will not ask if branch is already checked out locally, in this case only definition file will be updated. But if you delete those local branches and then say NO when asked, it will break flow on next branch ;/
PawelLipski: > if you delete those local branches and then say NO when asked, it will break flow on next branch ;/

OK, if the local branches corresponding to the PRs are missing, then let's just force-checkout them locally, even without asking the user &mdash; that's what I'd expect `checkout-pr` to do

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 233aa21..a0fc648 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,7 +31,7 @@ Deprecated commands are excluded.
 | expects no ongoing rebase/merge/cherry-pick/revert/am             | `advance`, `go`, `reapply`, `slide-out`, `squash`, `traverse`, `update`                                                                        |
 | has stable output format across minor versions (plumbing command) | `file`, `fork-point`<sup>[4]</sup>, `is-managed`, `list`, `show`, `version`                                                                    |
 
-[1]: `github` can only display status, accept interactive mode or modify git repository when `create-pr` subcommand is passed and the current branch is untracked or ahead of its remote.
+[1]: `github` can only display status, accept interactive mode or modify git repository when `create-pr` or `checkout-prs` subcommand is passed.
 
 [2]: `advance` can only run fast-forward merge (`git merge --ff-only`).
 
diff --git a/README.md b/README.md
index 32227df..71ad45b 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,11 @@ Annotate the branches with GitHub PR numbers: <br/>
 git machete github anno-prs
 ```
 
+Check out the PR into local branch, also traverse chain of pull requests upwards, adding branches one by one to git-machete and check them out locally: <br/>
+```shell script
+git machete github checkout-prs <PR_number>
+```
+
 Create the PR, using the upstream (parent) branch from `.git/machete` as the base: <br/>
 ```shell script
 git machete github create-pr [--draft]
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 4d618f6..f51f926 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,7 +2,7 @@
 
 ## New in git-machete 3.4.0
 
-- added: `github` command with `anno-prs`, `create-pr` and `retarget-pr` subcommands
+- added: `github` command with `anno-prs`, `checkout-prs`, `create-pr` and `retarget-pr` subcommands
 
 ## New in git-machete 3.3.1
 
diff --git a/completion/git-machete.completion.bash b/completion/git-machete.completion.bash
index c71d6aa..9a0b6f3 100644
--- a/completion/git-machete.completion.bash
+++ b/completion/git-machete.completion.bash
@@ -6,7 +6,7 @@ _git_machete() {
 
   local categories="addable managed slidable slidable-after unmanaged with-overridden-fork-point"
   local directions="down first last next prev root up"
-  local github_subcommands="anno-prs create-pr retarget-pr"
+  local github_subcommands="anno-prs checkout-prs create-pr retarget-pr"
   local locations="current $directions"
   local opt_color_args="always auto never"
   local opt_return_to_args="here nearest-remaining stay"
diff --git a/completion/git-machete.completion.zsh b/completion/git-machete.completion.zsh
index e7227a2..9641f15 100644
--- a/completion/git-machete.completion.zsh
+++ b/completion/git-machete.completion.zsh
@@ -217,6 +217,7 @@ __git_machete_github_subcommands() {
   local github_subcommands
   github_subcommands=(
     'anno-prs:annotate the branches based on their corresponding GitHub PR numbers and authors'
+    'checkout-prs:check out the given pull request locally'
     'create-pr:create a PR for the current branch, using the upstream (parent) branch as the PR base'
     'retarget-pr:set the base of the current branch PR to upstream (parent) branch'
   )
diff --git a/docs/source/cli_help/github.rst b/docs/source/cli_help/github.rst
index e6e0906..5c325a8 100644
--- a/docs/source/cli_help/github.rst
+++ b/docs/source/cli_help/github.rst
@@ -8,7 +8,7 @@ github
 
     git machete github <subcommand>
 
-where ``<subcommand>`` is one of: ``anno-prs``, ``create-pr``, ``retarget-pr``.
+where ``<subcommand>`` is one of: ``anno-prs``, ``checkout-prs``, ``create-pr``, ``retarget-pr``.
 
 Creates, checks out and manages GitHub PRs while keeping them reflected in branch definition file.
 
@@ -25,6 +25,16 @@ a GitHub API token with ``repo`` scope is required, see https://github.com/setti
   Any existing annotations are overwritten for the branches that have an opened PR; annotations for the other branches remain untouched.
   Equivalent to ``git machete anno --sync-github-prs``.
 
+``checkout-prs``:
+
+  Check out the head branch of the given pull request (specified by number),
+  also traverse chain of pull requests upwards, adding branches one by one to git-machete and check them out locally.
+  Once pull request is checked out locally, annotate local branches with corresponding pull request numbers.
+
+  **Parameters:**
+
+    <PR-number>    Pull request number to checkout.
+
 ``create-pr [--draft]``:
 
   Creates a PR for the current branch, using the upstream (parent) branch as the PR base.
diff --git a/git_machete/cli.py b/git_machete/cli.py
index e0d554f..a3d7d33 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -365,13 +365,27 @@ def launch(orig_args: List[str]) -> None:
             if dest != current_branch:
                 git.checkout(dest)
         elif cmd == "github":
-            github_allowed_subcommands = "anno-prs|create-pr|retarget-pr"
-            param = check_required_param(parse_options(args, "", ["draft"]),
-                                         github_allowed_subcommands)
+            github_allowed_subcommands = "anno-prs|checkout-prs|create-pr|retarget-pr"
+            list_args = parse_options(args) if args[0] == 'checkout-prs' \
+                else check_required_param(parse_options(args, "", ["draft"]), github_allowed_subcommands)
+            if not list_args:
+                raise MacheteException(f"`git machete github` expects argument(s): {github_allowed_subcommands}")
+
+            param = args[0]
             machete_client.read_definition_file()
             if param == "anno-prs":
                 machete_client.sync_annotations_to_github_prs()
+            elif param == "checkout-prs":
+                if len(list_args) == 1:
+                    raise MacheteException(
+                        "Argument to `git machete github checkout-prs` cannot be empty; expected PR number.")
+                try:
+                    pr_no: int = int(list_args[1])
+                except ValueError:
+                    raise MacheteException("PR number is not integer value!")
+                machete_client.checkout_github_prs(pr_no)
             elif param == "create-pr":
+                check_required_param(parse_options(args, "", ["draft"]), github_allowed_subcommands)
                 current_branch = git.get_current_branch()
                 machete_client.create_github_pr(current_branch, draft=cli_opts.opt_draft)
             elif param == "retarget-pr":
@@ -409,7 +423,7 @@ def launch(orig_args: List[str]) -> None:
             elif len(list_args) > 2:
                 raise MacheteException(f"Too many arguments to `git machete list {list_args[0]}` ")
             elif (list_args[0] in (
-                  "addable", "managed", "slidable", "unmanaged", "with-overridden-fork-point") and
+                    "addable", "managed", "slidable", "unmanaged", "with-overridden-fork-point") and
                   len(list_args) > 1):
                 raise MacheteException(
                     f"`git machete list {list_args[0]}` does not expect extra arguments")
@@ -495,8 +509,8 @@ def launch(orig_args: List[str]) -> None:
             machete_client.status(warn_on_yellow_edges=True)
         elif cmd == "traverse":
             traverse_long_opts = ["fetch", "list-commits", "merge",
-                                  "no-detect-squash-merges", "no-edit-merge",
-                                  "no-interactive-rebase",
+                                  "no-detect-squash-merges",
+                                  "no-edit-merge", "no-interactive-rebase",
                                   "no-push", "no-push-untracked", "push", "push-untracked",
                                   "return-to=", "start-from=", "whole", "yes"]
             expect_no_param(parse_options(args, "FlMnWwy", traverse_long_opts))
diff --git a/git_machete/client.py b/git_machete/client.py
index 132471e..f444579 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -15,9 +15,9 @@ from git_machete.constants import (
 from git_machete.exceptions import MacheteException, StopTraversal
 from git_machete.git_operations import GitContext
 from git_machete.github import (
-    GitHubPullRequest, derive_pull_request_by_head, set_base_of_pull_request,
-    get_parsed_github_remote_url, is_github_remote_url, create_pull_request,
-    derive_current_user_login, set_milestone_of_pull_request,
+    GitHubPullRequest, derive_pull_requests,
+    derive_pull_request_by_head, set_base_of_pull_request, get_parsed_github_remote_url, is_github_remote_url,
+    create_pull_request, derive_current_user_login, set_milestone_of_pull_request,
     add_assignees_to_pull_request, add_reviewers_to_pull_request)
 from git_machete.utils import (
     get_pretty_choices, flat_map, excluding, fmt, tupled, warn, debug, bold,
@@ -287,6 +287,7 @@ class MacheteClient:
                 self.__down_branches[onto] = [branch]
             print(fmt(f"Added branch `{branch}` onto `{onto}`"))
 
+        self.managed_branches += [branch]
         self.save_definition_file()
 
     def annotate(self, branch: str, words: List[str]) -> None:
@@ -1256,8 +1257,6 @@ class MacheteClient:
         return result
 
     def sync_annotations_to_github_prs(self) -> None:
-        from git_machete.github import derive_current_user_login, derive_pull_requests, GitHubPullRequest, \
-            get_parsed_github_remote_url
 
         url_for_remote: Dict[str, str] = {remote: self.__git.get_url_of_remote(remote) for remote in
                                           self.__git.remotes()}
@@ -1289,9 +1288,13 @@ class MacheteClient:
         debug('sync_annotations_to_github_prs()',
               'Current GitHub user is ' + (current_user or '<none>'))
         pr: GitHubPullRequest
-        for pr in derive_pull_requests(org, repo):
+        all_prs: List[GitHubPullRequest] = derive_pull_requests(org, repo)
+        self.__sync_annotations_to_definition_file(all_prs, current_user)
+
+    def __sync_annotations_to_definition_file(self, prs: List[GitHubPullRequest], current_user: Optional[str] = None) -> None:
+        for pr in prs:
             if pr.head in self.managed_branches:
-                debug('sync_annotations_to_github_prs()',
+                debug('sync_annotations_to_definition_file()',
                       f'{pr} corresponds to a managed branch')
                 anno: str = f'PR #{pr.number}'
                 if pr.user != current_user:
@@ -1305,7 +1308,7 @@ class MacheteClient:
                     print(fmt(f'Annotating <b>{pr.head}</b> as `{anno}`'))
                     self.__annotations[pr.head] = anno
             else:
-                debug('sync_annotations_to_github_prs()',
+                debug('sync_annotations_to_definition_file()',
                       f'{pr} does NOT correspond to a managed branch')
         self.save_definition_file()
 
@@ -1656,6 +1659,54 @@ class MacheteClient:
         self.__branch_defs_by_sha_in_reflog = None
         self.__git.flush_caches()
 
+    def checkout_github_prs(self, pr_no: int) -> None:
+        org: str
+        repo: str
+        remote: str
+        remote, (org, repo) = self.__derive_remote_and_github_org_and_repo()
+        debug('checkout_github_pr()', f'organization is {org}, repository is {repo}')
+
+        all_prs: List[GitHubPullRequest] = derive_pull_requests(org, repo)
+        pr = utils.find_or_none(lambda pr: pr.number == pr_no, all_prs)
+
+        if not pr:
+            raise MacheteException(f"PR #{pr_no} is not found in repository `{org}/{repo}`")
+        debug('checkout_github_pr()', f'found {pr}')
+
+        print(f"Fetching {remote}...")
+        self.__git.fetch_remote(remote)
+        if self.__git.remotes():
+            self.flush_caches()
+
+        self.__cli_opts.opt_yes = True  # TODO (#161): pass only needed options to methods
+        path: List[str] = self.__get_path_from_pr_chain(pr, all_prs)
+        reversed_path: List[str] = path[::-1]  # need to add from root downwards
+        for index, branch in enumerate(reversed_path):
+            if branch not in self.managed_branches:
+                if index == 0:
+                    self.__cli_opts.opt_as_root = True
+                    self.add(branch)
+                    self.__cli_opts.opt_as_root = False
+                else:
+                    self.__cli_opts.opt_onto = reversed_path[index - 1]
+                    self.add(branch)
+        self.__cli_opts.opt_yes = False
+
+        current_user: Optional[str] = derive_current_user_login()
+        debug('checkout_github_pr()',
+              'Current GitHub user is ' + (current_user or '<none>'))
+        self.__sync_annotations_to_definition_file(all_prs, current_user)
+
+        self.__git.checkout(pr.head)
+        print(fmt(f"Pull request `#{pr.number}` checked out at local branch `{pr.head}`"))
+
+    def __get_path_from_pr_chain(self, current_pr: GitHubPullRequest, all_prs: List[GitHubPullRequest]) -> List[str]:
+        path: List[str] = [current_pr.head]
+        while current_pr:
+            path.append(current_pr.base)
+            current_pr = utils.find_or_none(lambda x: x.head == current_pr.base, all_prs)
+        return path
+
     def retarget_github_pr(self, head: str) -> None:
         org: str
         repo: str
@@ -1695,7 +1746,7 @@ class MacheteClient:
         if not org_and_repo_for_github_remote:
             raise MacheteException(
                 fmt('Remotes are defined for this repository, but none of them '
-                    'corresponds to GitHub (see `git remote -v` for details)'))
+                    'corresponds to GitHub (see `git remote -v` for details)\n'))
 
         if len(org_and_repo_for_github_remote) == 1:
             return list(org_and_repo_for_github_remote.items())[0]
@@ -1714,7 +1765,7 @@ class MacheteClient:
         except MacheteException as e:
             print(e)
             return
-
+        self.flush_caches()
         base: Optional[str] = self.up_branch.get(head)
         if not base:
             raise MacheteException(
diff --git a/git_machete/docs.py b/git_machete/docs.py
index 3d90789..dfbc4c8 100644
--- a/git_machete/docs.py
+++ b/git_machete/docs.py
@@ -298,7 +298,7 @@ long_docs: Dict[str, str] = {
     """,
     "github": """
         <b>Usage: git machete github <subcommand></b>
-        where <subcommand> is one of: `anno-prs`, `create-pr`, `retarget-pr`.
+        where <subcommand> is one of: `anno-prs`, `checkout-prs`, `create-pr`, `retarget-pr`.
 
         Creates, checks out and manages GitHub PRs while keeping them reflected in branch definition file.
 
@@ -314,6 +314,15 @@ long_docs: Dict[str, str] = {
           Any existing annotations are overwritten for the branches that have an opened PR; annotations for the other branches remain untouched.
           Equivalent to `git machete anno --sync-github-prs`.
 
+        <b>`checkout-prs <PR-number>`:</b>
+
+          Check out the head branch of the given pull request (specified by number),
+          also traverse chain of pull requests upwards, adding branches one by one to git-machete and check them out locally.
+          Once pull request is checked out locally, annotate local branches with corresponding pull request numbers.
+
+          <b>Parameters:</b>
+            <b><PR-number></b>    Pull request number to checkout.
+
         <b>`create-pr [--draft]`:</b>
 
           Creates a PR for the current branch, using the upstream (parent) branch as the PR base.
diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py
index 573f47f..6a3734e 100644
--- a/git_machete/git_operations.py
+++ b/git_machete/git_operations.py
@@ -413,6 +413,7 @@ class GitContext:
         self.__reflogs_cached = None
         self.__remaining_log_shas_cached = {}
         self.__remote_branches_cached = None
+        self.__remotes_cached = None
 
     def get_revision_repr(self, revision: str) -> str:
         short_sha = self.get_short_commit_sha_by_revision(revision)
diff --git a/git_machete/github.py b/git_machete/github.py
index 2943a61..d322d39 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Any, Tuple
 import urllib.request
 from urllib.error import HTTPError
 
-from git_machete.utils import debug, fmt
+from git_machete.utils import debug, fmt, find_or_none
 from git_machete.exceptions import MacheteException, UnprocessableEntityHTTPError
 
 
@@ -143,13 +143,6 @@ def __fire_github_api_request(method: str, path: str, token: Optional[str], requ
         raise MacheteException(f'Could not connect to {host}: {e}')
 
 
-def __check_pr_already_created(pull: GitHubPullRequest, pull_requests: List[GitHubPullRequest]) -> Optional[GitHubPullRequest]:
-    for pr in pull_requests:
-        if pull.base == pr.base and pull.head == pr.head:
-            return pr
-    return None
-
-
 def create_pull_request(org: str, repo: str, head: str, base: str, title: str, description: str, draft: bool) -> GitHubPullRequest:
     token: Optional[str] = __get_github_token()
     request_body: Dict[str, Any] = {
@@ -160,8 +153,7 @@ def create_pull_request(org: str, repo: str, head: str, base: str, title: str, d
         'draft': draft
     }
     prs: List[GitHubPullRequest] = derive_pull_requests(org, repo)
-    to_load: GitHubPullRequest = GitHubPullRequest(1, 'user', base, head, '')
-    pr_found: Optional[GitHubPullRequest] = __check_pr_already_created(to_load, prs)
+    pr_found: Optional[GitHubPullRequest] = find_or_none(lambda pr: pr.base == base and pr.head == head, prs)
     if not pr_found:
         pr = __fire_github_api_request('POST', f'/repos/{org}/{repo}/pulls', token, request_body)
         return __parse_pr_json(pr)
diff --git a/git_machete/utils.py b/git_machete/utils.py
index eee4620..4672e73 100644
--- a/git_machete/utils.py
+++ b/git_machete/utils.py
@@ -28,6 +28,10 @@ def flat_map(func: Callable[[T], List[T]], iterable: Iterable[T]) -> List[T]:
     return sum(map(func, iterable), [])
 
 
+def find_or_none(func: Callable[[T], bool], iterable: Iterable[T]) -> T:
+    return next(filter(func, iterable), None)
+
+
 def map_truthy_only(func: Callable[[T], Optional[T]], iterable: Iterable[T]) -> List[T]:
     return list(filter(None, map(func, iterable)))
 


Test patch:
diff --git a/git_machete/tests/functional/test_machete.py b/git_machete/tests/functional/test_machete.py
index 84a31d9..97ff59d 100644
--- a/git_machete/tests/functional/test_machete.py
+++ b/git_machete/tests/functional/test_machete.py
@@ -17,6 +17,7 @@ from urllib.parse import urlparse, ParseResult, parse_qs
 from git_machete import cli
 from git_machete.client import MacheteClient
 from git_machete.exceptions import MacheteException
+from git_machete.github import get_parsed_github_remote_url
 from git_machete.git_operations import GitContext
 from git_machete.options import CommandLineOptions
 from git_machete.utils import fmt
@@ -24,6 +25,8 @@ from git_machete.utils import fmt
 cli_opts: CommandLineOptions = CommandLineOptions()
 git: GitContext = GitContext(cli_opts)
 
+FAKE_GITHUB_REMOTE_PATTERNS = ['(.*)/(.*)']
+
 
 def get_head_commit_hash() -> str:
     """Returns hash of a commit of the current branch head."""
@@ -124,7 +127,7 @@ class MockGithubAPIRequest:
             return self.make_response_object(HTTPStatus.NOT_FOUND, [])
 
     def update_pull_request(self) -> "MockGithubAPIResponse":
-        pull_no: str = self.find_number(self.parsed_url.path)
+        pull_no: str = self.find_number(self.parsed_url.path, 'pulls')
         if not pull_no:
             return self.create_pull_request()
         pull: Dict[str, Any] = self.github_api_state.get_pull(pull_no)
@@ -137,8 +140,9 @@ class MockGithubAPIRequest:
         return self.fill_pull_request_data(json.loads(self.json_data), pull)
 
     def fill_pull_request_data(self, data: Dict[str, Any], pull: Dict[str, Any]) -> "MockGithubAPIResponse":
-        index = self.get_index_or_None(pull, self.github_api_state.issues)
+        index = self.get_index_or_none(pull, self.github_api_state.issues)
         for key in data.keys():
+
             if key in ('base', 'head'):
                 pull[key] = {'ref': ""}
                 pull[key]['ref'] = json.loads(self.json_data)[key]
@@ -151,7 +155,7 @@ class MockGithubAPIRequest:
         return self.make_response_object(HTTPStatus.CREATED, pull)
 
     def update_issue(self) -> "MockGithubAPIResponse":
-        issue_no: str = self.find_number(self.parsed_url.path)
+        issue_no: str = self.find_number(self.parsed_url.path, 'issues')
         if not issue_no:
             return self.create_issue()
         issue: Dict[str, Any] = self.github_api_state.get_issue(issue_no)
@@ -162,7 +166,7 @@ class MockGithubAPIRequest:
         return self.fill_issue_data(json.loads(self.json_data), issue)
 
     def fill_issue_data(self, data: Dict[str, Any], issue: Dict[str, Any]) -> "MockGithubAPIResponse":
-        index = self.get_index_or_None(issue, self.github_api_state.issues)
+        index = self.get_index_or_none(issue, self.github_api_state.issues)
         for key in data.keys():
             issue[key] = data[key]
         if index:
@@ -172,7 +176,7 @@ class MockGithubAPIRequest:
         return self.make_response_object(HTTPStatus.CREATED, issue)
 
     @staticmethod
-    def get_index_or_None(entity: Dict[str, Any], base: List[Dict[str, Any]]) -> Optional[int]:
+    def get_index_or_none(entity: Dict[str, Any], base: List[Dict[str, Any]]) -> Optional[int]:
         try:
             return base.index(entity)
         except ValueError:
@@ -183,10 +187,10 @@ class MockGithubAPIRequest:
         return MockGithubAPIResponse(status_code, response_data)
 
     @staticmethod
-    def find_number(url: str) -> Optional[str]:
-        m = re.search(r'\d+', url)
+    def find_number(url: str, entity: str) -> Optional[str]:
+        m = re.search(f'{entity}/(\\d+)', url)
         if m:
-            return m.group()
+            return m.group(1)
         return None
 
     @staticmethod
@@ -1603,7 +1607,6 @@ class MacheteTester(unittest.TestCase):
     @mock.patch('urllib.request.urlopen', MockContextManager)
     @mock.patch('urllib.request.Request', git_api_state_for_test_anno_prs.new_request())
     def test_anno_prs(self) -> None:
-
         (
             self.repo_sandbox.new_branch("root")
                 .commit("root")
@@ -1673,7 +1676,6 @@ class MacheteTester(unittest.TestCase):
     @mock.patch('urllib.request.urlopen', MockContextManager)
     @mock.patch('urllib.request.Request', git_api_state_for_test_create_pr.new_request())
     def test_github_create_pr(self) -> None:
-
         (
             self.repo_sandbox.new_branch("root")
                 .commit("initial commit")
@@ -1714,8 +1716,8 @@ class MacheteTester(unittest.TestCase):
                 .delete_branch("root")
                 .new_branch('chore/fields')
                 .commit("remove outdated fields")
-                .add_remote('new_origin', 'https://github.com/user/repo.git')
                 .check_out("call-ws")
+                .add_remote('new_origin', 'https://github.com/user/repo.git')
         )
 
         self.launch_command("discover")
@@ -1808,3 +1810,256 @@ class MacheteTester(unittest.TestCase):
         if e:
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when given pull request to create is already created.')
+
+    git_api_state_for_test_checkout_prs = MockGithubAPIState([
+        {'head': {'ref': 'chore/redundant_checks'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'restrict_access'}, 'number': '18', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'restrict_access'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'allow-ownership-link'}, 'number': '17', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'allow-ownership-link'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'bugfix/feature'}, 'number': '12', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'bugfix/feature'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'enhance/feature'}, 'number': '6', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'enhance/add_user'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'develop'}, 'number': '19', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'testing/add_user'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'bugfix/add_user'}, 'number': '22', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'chore/comments'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'testing/add_user'}, 'number': '24', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'ignore-trailing'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'hotfix/add-trigger'}, 'number': '3', 'html_url': 'www.github.com'}
+    ])
+
+    # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_checkout_prs` due to `git fetch` executed by `checkout-prs` subcommand.
+    @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
+    @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
+    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('urllib.request.Request', git_api_state_for_test_checkout_prs.new_request())
+    def test_checkout_prs(self) -> None:
+        (
+            self.repo_sandbox.new_branch("root")
+            .commit("initial commit")
+            .new_branch("develop")
+            .commit("first commit")
+            .push()
+            .new_branch("enhance/feature")
+            .commit("introduce feature")
+            .push()
+            .new_branch("bugfix/feature")
+            .commit("bugs removed")
+            .push()
+            .new_branch("allow-ownership-link")
+            .commit("fixes")
+            .push()
+            .new_branch('restrict_access')
+            .commit('authorized users only')
+            .push()
+            .new_branch("chore/redundant_checks")
+            .commit('remove some checks')
+            .push()
+            .check_out("root")
+            .new_branch("master")
+            .commit("Master commit")
+            .push()
+            .new_branch("hotfix/add-trigger")
+            .commit("HOTFIX Add the trigger")
+            .push()
+            .new_branch("ignore-trailing")
+            .commit("Ignore trailing data")
+            .push()
+            .delete_branch("root")
+            .new_branch('chore/fields')
+            .commit("remove outdated fields")
+            .push()
+            .check_out('develop')
+            .new_branch('enhance/add_user')
+            .commit('allow externals to add users')
+            .push()
+            .new_branch('bugfix/add_user')
+            .commit('first round of fixes')
+            .push()
+            .new_branch('testing/add_user')
+            .commit('add test set for add_user feature')
+            .push()
+            .new_branch('chore/comments')
+            .commit('code maintenance')
+            .push()
+            .check_out('master')
+        )
+
+        for branch in ('chore/redundant_checks', 'restrict_access', 'allow-ownership-link', 'bugfix/feature', 'enhance/add_user', 'testing/add_user', 'chore/comments', 'bugfix/add_user'):
+            self.repo_sandbox.execute(f"git branch -D {branch}")
+
+        self.launch_command('discover')
+
+        # not broken chain of pull requests (root found in dependency tree)
+        self.launch_command('github', 'checkout-prs', '18')
+        self.assert_command(
+            ["status"],
+            """
+            master
+            |
+            o-hotfix/add-trigger
+              |
+              o-ignore-trailing  PR #3 (github_user)
+                |
+                o-chore/fields
+
+            develop
+            |
+            o-enhance/feature
+              |
+              o-bugfix/feature  PR #6 (github_user)
+                |
+                o-allow-ownership-link  PR #12 (github_user)
+                  |
+                  o-restrict_access  PR #17 (github_user)
+                    |
+                    o-chore/redundant_checks *  PR #18 (github_user)
+            """
+        )
+        # broken chain of pull requests (add new root)
+        self.launch_command('github', 'checkout-prs', '24')
+        self.assert_command(
+            ["status"],
+            """
+            master
+            |
+            o-hotfix/add-trigger
+              |
+              o-ignore-trailing  PR #3 (github_user)
+                |
+                o-chore/fields
+
+            develop
+            |
+            o-enhance/feature
+              |
+              o-bugfix/feature  PR #6 (github_user)
+                |
+                o-allow-ownership-link  PR #12 (github_user)
+                  |
+                  o-restrict_access  PR #17 (github_user)
+                    |
+                    o-chore/redundant_checks  PR #18 (github_user)
+
+            bugfix/add_user
+            |
+            o-testing/add_user  PR #22 (github_user)
+              |
+              o-chore/comments *  PR #24 (github_user)
+            """
+        )
+
+        # broken chain of pull requests (branches already added)
+        self.launch_command('github', 'checkout-prs', '24')
+        self.assert_command(
+            ["status"],
+            """
+            master
+            |
+            o-hotfix/add-trigger
+              |
+              o-ignore-trailing  PR #3 (github_user)
+                |
+                o-chore/fields
+
+            develop
+            |
+            o-enhance/feature
+              |
+              o-bugfix/feature  PR #6 (github_user)
+                |
+                o-allow-ownership-link  PR #12 (github_user)
+                  |
+                  o-restrict_access  PR #17 (github_user)
+                    |
+                    o-chore/redundant_checks  PR #18 (github_user)
+
+            bugfix/add_user
+            |
+            o-testing/add_user  PR #22 (github_user)
+              |
+              o-chore/comments *  PR #24 (github_user)
+            """
+        )
+        # check against wrong pr number
+        machete_client = MacheteClient(cli_opts, git)
+        repo: str
+        org: str
+        (org, repo) = get_parsed_github_remote_url(self.repo_sandbox.remote_path)
+        expected_error_message = f"PR #100 is not found in repository `{org}/{repo}`"
+        machete_client.read_definition_file()
+        with self.assertRaises(MacheteException) as e:
+            machete_client.checkout_github_prs(100)
+        if e:
+            self.assertEqual(e.exception.parameter, expected_error_message,
+                             'Verify that expected error message has appeared when given pull request to checkout does not exists.')
+
+    git_api_state_for_test_checkout_prs_fresh_repo = MockGithubAPIState([
+        {'head': {'ref': 'comments/add_docstrings'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'improve/refactor'}, 'number': '2', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'restrict_access'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'allow-ownership-link'}, 'number': '17', 'html_url': 'www.github.com'},
+        {'head': {'ref': 'improve/refactor'}, 'user': {'login': 'github_user'}, 'base': {'ref': 'chore/sync_to_docs'}, 'number': '1', 'html_url': 'www.github.com'},
+    ])
+
+    # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_checkout_prs_freshly_cloned` due to `git fetch` executed by `checkout-prs` subcommand.
+    @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
+    @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
+    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('urllib.request.Request', git_api_state_for_test_checkout_prs_fresh_repo.new_request())
+    def test_checkout_prs_freshly_cloned(self) -> None:
+        (
+            self.repo_sandbox.new_branch("root")
+            .commit("initial commit")
+            .new_branch("develop")
+            .commit("first commit")
+            .push()
+            .new_branch("chore/sync_to_docs")
+            .commit("synchronize docs")
+            .push()
+            .new_branch("improve/refactor")
+            .commit("refactor code")
+            .push()
+            .new_branch("comments/add_docstrings")
+            .commit("docstring added")
+            .push()
+            .check_out("root")
+            .new_branch("master")
+            .commit("Master commit")
+            .push()
+            .delete_branch('root')
+            .push()
+        )
+        for branch in ('develop', 'chore/sync_to_docs', 'improve/refactor', 'comments/add_docstrings'):
+            self.repo_sandbox.execute(f"git branch -D {branch}")
+
+        local_path = os.popen("mktemp -d").read().strip()
+        os.chdir(local_path)
+        self.repo_sandbox.execute(f'git clone {self.repo_sandbox.remote_path}')
+        os.chdir(os.path.join(local_path, os.listdir()[0]))
+
+        for branch in ('develop', 'chore/sync_to_docs', 'improve/refactor', 'comments/add_docstrings'):
+            self.repo_sandbox.execute(f"git branch -D -r origin/{branch}")
+
+        self.rewrite_definition_file("master")
+        expected_msg = ("Fetching origin...\n"
+                        "A local branch `chore/sync_to_docs` does not exist, but a remote branch `origin/chore/sync_to_docs` exists.\n"
+                        "Checking out `chore/sync_to_docs` locally...\n"
+                        "Added branch `chore/sync_to_docs` as a new root\n"
+                        "A local branch `improve/refactor` does not exist, but a remote branch `origin/improve/refactor` exists.\n"
+                        "Checking out `improve/refactor` locally...\n"
+                        "Added branch `improve/refactor` onto `chore/sync_to_docs`\n"
+                        "A local branch `comments/add_docstrings` does not exist, but a remote branch `origin/comments/add_docstrings` exists.\n"
+                        "Checking out `comments/add_docstrings` locally...\nAdded branch `comments/add_docstrings` onto `improve/refactor`\n"
+                        "Annotating comments/add_docstrings as `PR #2 (github_user)`\nAnnotating improve/refactor as `PR #1 (github_user)`\n"
+                        "Pull request `#2` checked out at local branch `comments/add_docstrings`\n")
+        self.assert_command(
+            ['github', 'checkout-prs', '2'],
+            expected_msg,
+            strip_indentation=False
+        )
+
+        self.assert_command(
+            ["status"],
+            """
+            master
+
+            chore/sync_to_docs
+            |
+            o-improve/refactor  PR #1 (github_user)
+              |
+              o-comments/add_docstrings *  PR #2 (github_user)
+            """
+        )