Repository: VirtusLab/git-machete
Pull request #359 (2021-11-25 12:58:47)

Release v3.6.0


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 02170d2..9e1ae63 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -30,11 +30,6 @@ jobs:
 
             # For ensure-links-correct.sh
             npm install --global remark-cli remark-lint-no-dead-urls remark-validate-links
-
-            # For the scripts doing `docker-compose pull` to work around https://github.com/docker/compose/issues/7258
-            wget -q https://github.com/mikefarah/yq/releases/download/v4.6.1/yq_linux_amd64
-            sudo mv yq_linux_amd64 /usr/local/bin/yq
-            sudo chmod +x /usr/local/bin/yq
       - run:
           name: Perform initial checks
           command: |
@@ -108,7 +103,7 @@ jobs:
           command: bash ci/pypi/ci-deploy.sh --dry-run
 
       - deploy:
-          name: '[Dry run] Update Homebrew tap'
+          name: '[Dry run] Update Homebrew tap with local installation test'
           # Prerequisites: artifacts must be released to test PyPI first
           command: bash ci/homebrew-tap/ci-deploy.sh --dry-run
 
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 4cb7056..d31eff9 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,10 @@
 # Release notes
 
+## New in git-machete 3.6.0
+
+- added: `t` alias for `traverse` command
+- fixed: remove underscore from `--start-from` flag for `traverse` subcommand
+
 ## New in git-machete 3.5.0
 
 - added: new way of acquiring the github token (from `~/.github-token`)
diff --git a/ci/homebrew-tap/ci-deploy.sh b/ci/homebrew-tap/ci-deploy.sh
index bd08662..fc9c6bb 100644
--- a/ci/homebrew-tap/ci-deploy.sh
+++ b/ci/homebrew-tap/ci-deploy.sh
@@ -21,6 +21,7 @@ sha256=$(
   curl -s https://$pypi_host/pypi/git-machete/$version/json \
   | jq --raw-output '.urls | map(select(.packagetype == "sdist")) | .[0].digests.sha256')
 sed -i "s/git-machete-.*\.tar\.gz/git-machete-$version.tar.gz/" git-machete.rb
+sed -i "s/pypi\.io/$pypi_host/" git-machete.rb
 sed -i "s/^  sha256 .*/  sha256 \"$sha256\"/" git-machete.rb
 git add git-machete.rb
 git commit --message "Release $version, CircleCI build: $CIRCLE_BUILD_NUM"
@@ -29,4 +30,14 @@ if [[ $do_push == true ]]; then
   git push origin master
 else
   echo "Refraining from push since it's a dry run"
+  # install git-machete from local formula with homebrew
+  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/null
+  eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
+  brew install ./git-machete.rb
+  if [[ "$version" != "$(git machete --version | cut -d' ' -f4)" ]]; then
+    echo "Something went wrong during brew installation: installed version does not match version from formula."
+    echo "Formula version: $version, installed version: $(git machete --version | cut -d' ' -f4)"
+    exit 1
+  fi
+  brew remove git-machete
 fi
diff --git a/docs/source/cli_help/traverse.rst b/docs/source/cli_help/traverse.rst
index 8b23310..d74e384 100644
--- a/docs/source/cli_help/traverse.rst
+++ b/docs/source/cli_help/traverse.rst
@@ -13,10 +13,10 @@ traverse
 
 .. code-block:: shell
 
-    git machete traverse [-F|--fetch] [-l|--list-commits] [-M|--merge]
-                         [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges]
-                         [--[no-]push] [--[no-]push-untracked]
-                         [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes]
+    git machete t[raverse] [-F|--fetch] [-l|--list-commits] [-M|--merge]
+                           [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges]
+                           [--[no-]push] [--[no-]push-untracked]
+                           [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes]
 
 Traverses the branch dependency tree in pre-order (i.e. simply in the order as they occur in the definition file).
 By default traverse starts from current branch, however this behaviour can be customized using options: ``--start-from=``, ``--whole`` or ``-w``, ``-W``.
diff --git a/git_machete/__init__.py b/git_machete/__init__.py
index 01bd03c..826cf62 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.5.0'
+__version__ = '3.6.0'
diff --git a/git_machete/cli.py b/git_machete/cli.py
index 8824ec9..34d369c 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -26,7 +26,8 @@ alias_by_command: Dict[str, str] = {
     "edit": "e",
     "go": "g",
     "log": "l",
-    "status": "s"
+    "status": "s",
+    "traverse": "t"
 }
 
 command_groups: List[Tuple[str, List[str]]] = [
@@ -316,6 +317,7 @@ def create_cli_parser() -> argparse.ArgumentParser:
 
     traverse_parser = subparsers.add_parser(
         'traverse',
+        aliases=['t'],
         argument_default=argparse.SUPPRESS,
         usage=argparse.SUPPRESS,
         add_help=False,
@@ -332,7 +334,7 @@ def create_cli_parser() -> argparse.ArgumentParser:
     traverse_parser.add_argument('--push-untracked', action='store_true')
     traverse_parser.add_argument('--no-push-untracked', action='store_true')
     traverse_parser.add_argument('--return-to')
-    traverse_parser.add_argument('--start_from')
+    traverse_parser.add_argument('--start-from')
     traverse_parser.add_argument('-w', '--whole', action='store_true')
     traverse_parser.add_argument('-W', action='store_true')
     traverse_parser.add_argument('-y', '--yes', action='store_true')
@@ -748,7 +750,7 @@ def launch(orig_args: List[str]) -> None:
                 opt_list_commits=cli_opts.opt_list_commits,
                 opt_list_commits_with_hashes=cli_opts.opt_list_commits_with_hashes,
                 opt_no_detect_squash_merges=cli_opts.opt_no_detect_squash_merges)
-        elif cmd == "traverse":
+        elif cmd in {"traverse", alias_by_command["traverse"]}:
             if cli_opts.opt_start_from not in {"here", "root", "first-root"}:
                 raise MacheteException(
                     "Invalid argument for `--start-from`. "
diff --git a/git_machete/client.py b/git_machete/client.py
index 0b32f19..a48feae 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -12,8 +12,10 @@ from git_machete import utils
 from git_machete.constants import (
     DISCOVER_DEFAULT_FRESH_BRANCH_COUNT, PICK_FIRST_ROOT, PICK_LAST_ROOT,
     EscapeCodes, SyncToRemoteStatuses)
-from git_machete.exceptions import MacheteException, StopInteraction
-from git_machete.git_operations import AnyBranchName, AnyRevision, FullCommitHash, GitContext, HEAD, LocalBranchShortName, RemoteBranchShortName
+from git_machete.exceptions import MacheteException, StopInteraction, UnprocessableEntityHTTPError
+from git_machete.git_operations import (
+    AnyBranchName, AnyRevision, ForkPointOverrideData,
+    FullCommitHash, GitContext, HEAD, LocalBranchShortName, RemoteBranchShortName)
 from git_machete.github import (
     add_assignees_to_pull_request, add_reviewers_to_pull_request,
     create_pull_request, checkout_pr_refs, derive_pull_request_by_head, derive_pull_requests,
@@ -1483,7 +1485,9 @@ class MacheteClient:
             debug(f"match_log_to_filtered_reflogs({branch})",
                   "branches containing the given SHA in their filtered reflog: \n%s\n" % "\n".join(log_result()))
 
-        for sha in self.__git.spoonfeed_log_shas(branch):
+        branch_full_hash: FullCommitHash = self.__git.get_commit_sha_by_revision(branch)
+
+        for sha in self.__git.spoonfeed_log_shas(branch_full_hash):
             if sha in self.__branch_defs_by_sha_in_reflog:
                 # The entries must be sorted by lb_or_rb to make sure the
                 # upstream inference is deterministic (and does not depend on the
@@ -1536,7 +1540,7 @@ class MacheteClient:
         return (self.__git.get_config_attr_or_none(self.config_key_for_override_fork_point_to(branch)) or
                 self.__git.get_config_attr_or_none(self.config_key_for_override_fork_point_while_descendant_of(branch))) is not None
 
-    def __get_fork_point_override_data(self, branch: LocalBranchShortName) -> Optional[Tuple[FullCommitHash, FullCommitHash]]:
+    def __get_fork_point_override_data(self, branch: LocalBranchShortName) -> Optional[ForkPointOverrideData]:
         to_key = self.config_key_for_override_fork_point_to(branch)
         to = FullCommitHash.of(self.__git.get_config_attr_or_none(to_key) or "")
         while_descendant_of_key = self.config_key_for_override_fork_point_while_descendant_of(branch)
@@ -1567,14 +1571,14 @@ class MacheteClient:
                 f"is not an ancestor of commit {self.__git.get_short_commit_sha_by_revision(while_descendant_of)} "
                 f"pointed by {while_descendant_of_key} config")
             return None
-        return to_sha, while_descendant_of_sha
+        return ForkPointOverrideData(to_sha, while_descendant_of_sha)
 
     def __get_overridden_fork_point(self, branch: LocalBranchShortName) -> Optional[FullCommitHash]:
-        override_data = self.__get_fork_point_override_data(branch)
+        override_data: ForkPointOverrideData = self.__get_fork_point_override_data(branch)
         if not override_data:
             return None
 
-        to, while_descendant_of = override_data
+        to, while_descendant_of = override_data.to_hash, override_data.while_descendant_of_hash
         # Note that this check is distinct from the is_ancestor check performed in
         # get_fork_point_override_data.
         # While the latter checks the sanity of fork point override configuration,
@@ -1629,6 +1633,7 @@ class MacheteClient:
             *,
             branch: LocalBranchShortName,
             is_called_from_traverse: bool,
+            is_called_from_create_pr: bool,
             opt_push_untracked: bool,
             opt_push_tracked: bool,
             opt_yes: bool
@@ -1652,6 +1657,7 @@ class MacheteClient:
                 new_remote=rems[index],
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=is_called_from_create_pr,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -1665,12 +1671,13 @@ class MacheteClient:
             new_remote: str,
             branch: LocalBranchShortName,
             is_called_from_traverse: bool,
+            is_called_from_create_pr: bool,
             opt_push_untracked: bool,
             opt_push_tracked: bool,
             opt_yes: bool
     ) -> None:
         rems: List[str] = self.__git.get_remotes()
-        can_pick_other_remote = len(rems) > 1
+        can_pick_other_remote = len(rems) > 1 and not is_called_from_create_pr
         other_remote_choice = "o[ther-remote]" if can_pick_other_remote else ""
         remote_branch = RemoteBranchShortName.of(f"{new_remote}/{branch}")
         if not self.__git.get_commit_sha_by_revision(remote_branch):
@@ -1692,6 +1699,7 @@ class MacheteClient:
                     self.__pick_remote(
                         branch=branch,
                         is_called_from_traverse=is_called_from_traverse,
+                        is_called_from_create_pr=is_called_from_create_pr,
                         opt_push_untracked=opt_push_untracked,
                         opt_push_tracked=opt_push_tracked,
                         opt_yes=opt_yes)
@@ -1706,6 +1714,7 @@ class MacheteClient:
                     self.__pick_remote(
                         branch=branch,
                         is_called_from_traverse=is_called_from_traverse,
+                        is_called_from_create_pr=is_called_from_create_pr,
                         opt_push_untracked=opt_push_untracked,
                         opt_push_tracked=opt_push_tracked,
                         opt_yes=opt_yes)
@@ -1781,6 +1790,7 @@ class MacheteClient:
             self.__pick_remote(
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=is_called_from_create_pr,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -1992,9 +2002,24 @@ class MacheteClient:
         self.flush_caches()
 
         base: Optional[LocalBranchShortName] = self.up_branch.get(LocalBranchShortName.of(head))
+        if not base:
+            raise MacheteException(f'Could not determine base branch for PR. Branch `{head}` is a root branch.')
         org: str
         repo: str
-        _, (org, repo) = self.__derive_remote_and_github_org_and_repo()
+        remote, (org, repo) = self.__derive_remote_and_github_org_and_repo()
+        print(f"Fetching {remote}...")
+        self.__git.fetch_remote(remote)
+        if '/'.join([remote, base]) not in self.__git.get_remote_branches():
+            warn(f'Base branch for this PR (`{base}`) is not found on remote, pushing...')
+            self.handle_untracked_branch(
+                branch=base,
+                new_remote=remote,
+                is_called_from_traverse=False,
+                is_called_from_create_pr=True,
+                opt_push_tracked=False,
+                opt_push_untracked=True,
+                opt_yes=False)
+
         current_user: Optional[str] = git_machete.github.derive_current_user_login()
         debug(f'create_github_pr({head})', f'organization is {org}, repository is {repo}')
         debug(f'create_github_pr({head})', 'current GitHub user is ' + (current_user or '<none>'))
@@ -2027,7 +2052,14 @@ class MacheteClient:
             print(
                 fmt(f'Adding {", ".join(f"`{reviewer}`" for reviewer in reviewers)} as reviewer{"s" if len(reviewers) > 1 else ""} to PR #{pr.number}...'),
                 end='', flush=True)
-            add_reviewers_to_pull_request(org, repo, pr.number, reviewers)
+            try:
+                add_reviewers_to_pull_request(org, repo, pr.number, reviewers)
+            except UnprocessableEntityHTTPError as e:
+                if 'Reviews may only be requested from collaborators.' in e.msg:
+                    warn(f"There are some invalid reviewers in {self.__git.get_git_subpath('info', 'reviewers')} file.\n"
+                         "Skipping adding reviewers to pull request.")
+                else:
+                    raise e
             print(fmt(ok_str))
 
         self.__annotations[head] = f'PR #{pr.number}'
@@ -2077,13 +2109,16 @@ class MacheteClient:
                 new_remote=rmt,
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
         elif len(rems) == 1:
             self.handle_untracked_branch(
-                new_remote=rems[0], branch=branch,
+                new_remote=rems[0],
+                branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -2092,6 +2127,7 @@ class MacheteClient:
                 new_remote="origin",
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -2101,6 +2137,7 @@ class MacheteClient:
             self.__pick_remote(
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
diff --git a/git_machete/docs.py b/git_machete/docs.py
index 513561d..803bb3d 100644
--- a/git_machete/docs.py
+++ b/git_machete/docs.py
@@ -606,10 +606,10 @@ long_docs: Dict[str, str] = {
                                             when detecting if a branch is merged into its upstream (parent).
     """,
     "traverse": """
-        <b>Usage: git machete traverse [-F|--fetch] [-l|--list-commits] [-M|--merge]
-                                       [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges]
-                                       [--[no-]push] [--[no-]push-untracked]
-                                       [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes]</b>
+        <b>Usage: git machete t[raverse] [-F|--fetch] [-l|--list-commits] [-M|--merge]
+                                         [-n|--no-edit-merge|--no-interactive-rebase] [--no-detect-squash-merges]
+                                         [--[no-]push] [--[no-]push-untracked]
+                                         [--return-to=WHERE] [--start-from=WHERE] [-w|--whole] [-W] [-y|--yes]</b>
 
         Traverses the branch dependency tree in pre-order (i.e. simply in the order as they occur in the definition file).
         By default traverse starts from current branch, however this behaviour can be customized using options: `--starts-from=`, `--whole` or `-w`, `-W`.
diff --git a/git_machete/exceptions.py b/git_machete/exceptions.py
index a1f9893..ae09ea3 100644
--- a/git_machete/exceptions.py
+++ b/git_machete/exceptions.py
@@ -21,7 +21,7 @@ class UnprocessableEntityHTTPError(MacheteException):
     or creating a pull request for a branch that already has a PR.
     """
     def __init__(self, msg: str) -> None:
-        self.msg: str = 'UnprocessableEntityHTTPError: ' + msg
+        self.msg: str = msg
 
     def __str__(self) -> str:
         return str(self.msg)
diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py
index b969f91..d060205 100644
--- a/git_machete/git_operations.py
+++ b/git_machete/git_operations.py
@@ -126,10 +126,10 @@ class ShortCommitHash(AnyRevision):
     @staticmethod
     def of(value: str) -> Optional["ShortCommitHash"]:
         if value:
-            if len(value) == 7:
+            if len(value) >= 7:
                 return ShortCommitHash(value)
             else:
-                raise TypeError(f'ShortCommitHash requires length of 7. Given value: {value}, has length: {len(value)}.\n'
+                raise TypeError(f'ShortCommitHash requires length greater or equal to 7. Given value: {value}, has length: {len(value)}.\n'
                                 'Consider posting an issue on https://github.com/VirtusLab/git-machete/issues/new')
         return None
 
@@ -143,8 +143,15 @@ class FullTreeHash(str):
         return FullTreeHash(value) if value else None
 
 
+class ForkPointOverrideData:
+    def __init__(self, to_hash: FullCommitHash, while_descendant_of_hash: FullCommitHash):
+        self.to_hash: FullCommitHash = to_hash
+        self.while_descendant_of_hash: FullCommitHash = while_descendant_of_hash
+
+
 ReflogEntry = Tuple[FullCommitHash, str]
 
+
 HEAD = AnyRevision.of("HEAD")
 
 
@@ -164,8 +171,8 @@ class GitContext:
         self.__committer_unix_timestamp_by_revision_cached: Optional[Dict[AnyRevision, int]] = None  # TODO (#110): default dict with 0
         self.__local_branches_cached: Optional[List[LocalBranchShortName]] = None
         self.__remote_branches_cached: Optional[List[RemoteBranchShortName]] = None
-        self.__initial_log_shas_cached: Dict[LocalBranchShortName, List[FullCommitHash]] = {}
-        self.__remaining_log_shas_cached: Dict[LocalBranchShortName, List[FullCommitHash]] = {}
+        self.__initial_log_shas_cached: Dict[FullCommitHash, List[FullCommitHash]] = {}
+        self.__remaining_log_shas_cached: Dict[FullCommitHash, List[FullCommitHash]] = {}
         self.__reflogs_cached: Optional[Dict[AnyBranchName, Optional[List[ReflogEntry]]]] = None
         self.__merge_base_cached: Dict[Tuple[FullCommitHash, FullCommitHash], FullCommitHash] = {}
         self.__contains_equivalent_tree_cached: Dict[Tuple[FullCommitHash, FullCommitHash], bool] = {}
@@ -355,6 +362,8 @@ class GitContext:
             return None
 
     def get_commit_sha_by_revision(self, revision: AnyRevision) -> Optional[FullCommitHash]:
+        if self.is_full_sha(revision.full_name()):
+            return FullCommitHash.of(revision)
         if self.__commit_sha_by_revision_cached is None:
             self.__load_branches()
         if revision not in self.__commit_sha_by_revision_cached:
@@ -378,13 +387,6 @@ class GitContext:
     def is_full_sha(revision: AnyRevision) -> Optional[Match[str]]:
         return re.match("^[0-9a-f]{40}$", revision)
 
-    # Resolve a revision identifier to a full sha
-    def get_full_sha(self, revision: AnyRevision) -> Optional[FullCommitHash]:
-        if self.is_full_sha(revision.full_name()):
-            return FullCommitHash.of(revision)
-        else:
-            return self.get_commit_sha_by_revision(revision)
-
     def get_committer_unix_timestamp_by_revision(self, revision: AnyBranchName) -> int:
         if self.__committer_unix_timestamp_by_revision_cached is None:
             self.__load_branches()
@@ -490,15 +492,15 @@ class GitContext:
     # Since getting the full history of a branch can be an expensive operation for large repositories (compared to all other underlying git operations),
     # there's a simple optimization in place: we first fetch only a couple of first commits in the history,
     # and only fetch the rest if needed.
-    def spoonfeed_log_shas(self, branch: LocalBranchShortName) -> Generator[FullCommitHash, None, None]:
-        if branch not in self.__initial_log_shas_cached:
-            self.__initial_log_shas_cached[branch] = self.__get_log_shas(branch, max_count=MAX_COUNT_FOR_INITIAL_LOG)
-        for sha in self.__initial_log_shas_cached[branch]:
+    def spoonfeed_log_shas(self, branch_full_hash: FullCommitHash) -> Generator[FullCommitHash, None, None]:
+        if branch_full_hash not in self.__initial_log_shas_cached:
+            self.__initial_log_shas_cached[branch_full_hash] = self.__get_log_shas(branch_full_hash, max_count=MAX_COUNT_FOR_INITIAL_LOG)
+        for sha in self.__initial_log_shas_cached[branch_full_hash]:
             yield FullCommitHash.of(sha)
 
-        if branch not in self.__remaining_log_shas_cached:
-            self.__remaining_log_shas_cached[branch] = self.__get_log_shas(branch, max_count=None)[MAX_COUNT_FOR_INITIAL_LOG:]
-        for sha in self.__remaining_log_shas_cached[branch]:
+        if branch_full_hash not in self.__remaining_log_shas_cached:
+            self.__remaining_log_shas_cached[branch_full_hash] = self.__get_log_shas(branch_full_hash, max_count=None)[MAX_COUNT_FOR_INITIAL_LOG:]
+        for sha in self.__remaining_log_shas_cached[branch_full_hash]:
             yield FullCommitHash.of(sha)
 
     def __load_all_reflogs(self) -> None:
@@ -552,16 +554,11 @@ class GitContext:
         self.__remotes_cached = None
         self.__counterparts_for_fetching_cached = None
         self.__short_commit_sha_by_revision_cached = {}
-        self.__tree_sha_by_commit_sha_cached = None
         self.__commit_sha_by_revision_cached = None
         self.__committer_unix_timestamp_by_revision_cached = None
         self.__local_branches_cached = None
         self.__remote_branches_cached = None
-        self.__initial_log_shas_cached = {}
-        self.__remaining_log_shas_cached = {}
         self.__reflogs_cached = None
-        self.__merge_base_cached = {}
-        self.__contains_equivalent_tree_cached = {}
 
     def get_revision_repr(self, revision: AnyRevision) -> str:
         short_sha = self.get_short_commit_sha_by_revision(revision)
@@ -651,8 +648,8 @@ class GitContext:
             earlier_revision: AnyRevision,
             later_revision: AnyRevision,
     ) -> bool:
-        earlier_sha = self.get_full_sha(earlier_revision)
-        later_sha = self.get_full_sha(later_revision)
+        earlier_sha = self.get_commit_sha_by_revision(earlier_revision)
+        later_sha = self.get_commit_sha_by_revision(later_revision)
         # This if statement is not changing the outcome of the later return, but
         # it enhances the efficiency of the script. If both hashes are the same,
         # there is no point running git merge-base.
@@ -669,8 +666,8 @@ class GitContext:
             earlier_revision: AnyRevision,
             later_revision: AnyRevision,
     ) -> bool:
-        earlier_commit_sha = self.get_full_sha(earlier_revision)
-        later_commit_sha = self.get_full_sha(later_revision)
+        earlier_commit_sha = self.get_commit_sha_by_revision(earlier_revision)
+        later_commit_sha = self.get_commit_sha_by_revision(later_revision)
 
         if earlier_commit_sha == later_commit_sha:
             return True
diff --git a/git_machete/github.py b/git_machete/github.py
index 6772f61..8b246d4 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -9,9 +9,9 @@ import subprocess
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple
 import urllib.request
-from urllib.error import HTTPError
+import urllib.error
 
-from git_machete.utils import debug, fmt, find_or_none
+from git_machete.utils import debug, fmt
 from git_machete.exceptions import MacheteException, UnprocessableEntityHTTPError
 from git_machete.git_operations import GitContext, LocalBranchShortName
 
@@ -125,6 +125,19 @@ def __get_github_token() -> Optional[str]:
             get_token_from_hub())
 
 
+def __extract_failure_info_from_422(response: Any) -> str:
+    if response['message'] != 'Validation Failed':
+        return str(response['message'])
+    ret: List[str] = []
+    if response.get('errors'):
+        for error in response['errors']:
+            if error.get('message'):
+                ret.append(error['message'])
+    if ret:
+        return '\n'.join(ret)
+    return str(response)
+
+
 def __fire_github_api_request(method: str, path: str, token: Optional[str], request_body: Optional[Dict[str, Any]] = None) -> Any:
     headers: Dict[str, str] = {
         'Content-type': 'application/json',
@@ -145,10 +158,10 @@ def __fire_github_api_request(method: str, path: str, token: Optional[str], requ
         with urllib.request.urlopen(http_request) as response:
             parsed_response_body: Any = json.loads(response.read().decode())
             return parsed_response_body
-    except HTTPError as err:
+    except urllib.error.HTTPError as err:
         if err.code == http.HTTPStatus.UNPROCESSABLE_ENTITY:
             error_response = json.loads(err.read().decode())
-            error_reason: str = error_response['errors'][0]['message']
+            error_reason: str = __extract_failure_info_from_422(error_response)
             raise UnprocessableEntityHTTPError(error_reason)
         elif err.code in (http.HTTPStatus.UNAUTHORIZED, http.HTTPStatus.FORBIDDEN):
             first_line = f'GitHub API returned {err.code} HTTP status with error message: `{err.reason}`\n'
@@ -180,13 +193,8 @@ def create_pull_request(org: str, repo: str, head: str, base: str, title: str, d
         'body': description,
         'draft': draft
     }
-    prs: List[GitHubPullRequest] = derive_pull_requests(org, repo)
-    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)
-    else:
-        raise MacheteException(f'Pull request for branch {head} is already created under link {pr_found.html_url}!\nPR details: {pr_found}')
+    pr = __fire_github_api_request('POST', f'/repos/{org}/{repo}/pulls', token, request_body)
+    return __parse_pr_json(pr)
 
 
 def add_assignees_to_pull_request(org: str, repo: str, number: int, assignees: List[str]) -> None:


Test patch:
diff --git a/git_machete/tests/functional/test_machete.py b/git_machete/tests/functional/test_machete.py
index 36165d7..53cef1e 100644
--- a/git_machete/tests/functional/test_machete.py
+++ b/git_machete/tests/functional/test_machete.py
@@ -174,6 +174,9 @@ class MockGithubAPIRequest:
     def update_pull_request(self) -> "MockGithubAPIResponse":
         pull_no: str = self.find_number(self.parsed_url.path, 'pulls')
         if not pull_no:
+            if self.is_pull_created():
+                return self.make_response_object(HTTPStatus.UNPROCESSABLE_ENTITY, {'message': 'Validation Failed', 'errors': [
+                    {'message': f'A pull request already exists for test_repo:{json.loads(self.json_data)["head"]}.'}]})
             return self.create_pull_request()
         pull: Dict[str, Any] = self.github_api_state.get_pull(pull_no)
         return self.fill_pull_request_data(json.loads(self.json_data), pull)
@@ -221,6 +224,17 @@ class MockGithubAPIRequest:
             self.github_api_state.issues.append(issue)
         return self.make_response_object(HTTPStatus.CREATED, issue)
 
+    def is_pull_created(self) -> bool:
+        deserialized_json_data = json.loads(self.json_data)
+        head: str = deserialized_json_data['head']
+        base: str = deserialized_json_data['base']
+        for pull in self.github_api_state.pulls:
+            pull_head: str = pull['head']['ref']
+            pull_base: str = pull['base']['ref']
+            if (head, base) == (pull_head, pull_base):
+                return True
+        return False
+
     @staticmethod
     def get_index_or_none(entity: Dict[str, Any], base: List[Dict[str, Any]]) -> Optional[int]:
         try:
@@ -245,6 +259,15 @@ class MockGithubAPIRequest:
         return str(max(numbers) + 1)
 
 
+class MockHTTPError(HTTPError):
+    def __init__(self, url: str, code: int, msg: Any, hdrs: Dict[str, str], fp: Any) -> None:
+        super().__init__(url, code, msg, hdrs, fp)
+        self.msg = msg
+
+    def read(self, n: int = 1) -> bytes:
+        return json.dumps(self.msg).encode()
+
+
 class MockContextManager:
     def __init__(self, obj: MockGithubAPIResponse) -> None:
         self.obj = obj
@@ -252,6 +275,8 @@ class MockContextManager:
     def __enter__(self) -> MockGithubAPIResponse:
         if self.obj.status_code == HTTPStatus.NOT_FOUND:
             raise HTTPError(None, 404, 'Not found', None, None)
+        elif self.obj.status_code == HTTPStatus.UNPROCESSABLE_ENTITY:
+            raise MockHTTPError(None, 422, self.obj.response_data, None, None)
         return self.obj
 
     def __exit__(self, *args: Any) -> None:
@@ -513,8 +538,39 @@ class MacheteTester(unittest.TestCase):
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     def test_traverse_no_push_override(self) -> None:
         self.setup_discover_standard_tree()
+        self.repo_sandbox.check_out("hotfix/add-trigger")
+        self.launch_command("t", "-Wy", "--no-push", "--push", "--start-from=here")
+        self.assert_command(
+            ["status", "-l"],
+            """
+            develop
+            |
+            | Allow ownership links
+            | 1st round of fixes
+            x-allow-ownership-link (ahead of origin)
+            | |
+            | | Build arbitrarily long chains
+            | x-build-chain (untracked)
+            |
+            | Call web service
+            | 1st round of fixes
+            | 2nd round of fixes
+            o-call-ws (ahead of origin)
+              |
+              | Drop unneeded SQL constraints
+              x-drop-constraint (untracked)
 
-        self.launch_command("traverse", "-Wy", "--no-push", "--push")
+            master
+            |
+            | HOTFIX Add the trigger (amended)
+            o-hotfix/add-trigger *
+              |
+              | Ignore trailing data (amended)
+              o-ignore-trailing
+            """,
+        )
+        self.repo_sandbox.check_out("ignore-trailing")
+        self.launch_command("t", "-Wy", "--no-push", "--push")
         self.assert_command(
             ["status", "-l"],
             """
@@ -1927,6 +1983,9 @@ class MacheteTester(unittest.TestCase):
     git_api_state_for_test_create_pr = MockGithubAPIState([{'head': {'ref': 'ignore-trailing', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'hotfix/add-trigger'}, 'number': '3', 'html_url': 'www.github.com', 'state': 'open'}],
                                                           issues=[{'number': '4'}, {'number': '5'}, {'number': '6'}])
 
+    @mock.patch('urllib.error.HTTPError', MockHTTPError)  # need to provide read() method, which does not actually reads error from url
+    # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
+    @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
@@ -2060,12 +2119,12 @@ class MacheteTester(unittest.TestCase):
         )
         # check against attempt to create already existing pull request
         machete_client = MacheteClient(git)
-        expected_error_message = "Pull request for branch hotfix/add-trigger is already created under link www.github.com!\nPR details: PR #6 by github_user: hotfix/add-trigger -> master"
+        expected_error_message = "A pull request already exists for test_repo:hotfix/add-trigger."
         machete_client.read_definition_file()
         with self.assertRaises(MacheteException) as e:
             machete_client.create_github_pr(head=LocalBranchShortName.of('hotfix/add-trigger'), opt_draft=False, opt_onto=None)
         if e:
-            self.assertEqual(e.exception.parameter, expected_error_message,
+            self.assertEqual(e.exception.msg, expected_error_message,  # type: ignore
                              'Verify that expected error message has appeared when given pull request to create is already created.')
 
         # check against head branch is ancestor or equal to base branch
@@ -2093,6 +2152,47 @@ class MacheteTester(unittest.TestCase):
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when creating PR from root branch.')
 
+    git_api_state_for_test_create_pr_missing_base_branch_on_remote = MockGithubAPIState([{'head': {'ref': 'chore/redundant_checks', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'restrict_access'}, 'number': '18', 'html_url': 'www.github.com', 'state': 'open'}])
+
+    # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
+    @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
+    @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
+    @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
+    @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
+    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('urllib.request.Request', git_api_state_for_test_create_pr_missing_base_branch_on_remote.new_request())
+    def test_github_create_pr_missing_base_branch_on_remote(self) -> None:
+        (
+            self.repo_sandbox.new_branch("root")
+                .commit("initial commit")
+                .new_branch("develop")
+                .commit("first commit on develop")
+                .push()
+                .new_branch("feature/api_handling")
+                .commit("Introduce GET and POST methods on API")
+                .new_branch("feature/api_exception_handling")
+                .commit("catch exceptions coming from API")
+                .push()
+                .delete_branch("root")
+        )
+
+        self.launch_command('discover')
+
+        expected_msg = ("Fetching origin...\n"
+                        "Warn: Base branch for this PR (`feature/api_handling`) is not found on remote, pushing...\n"
+                        "Creating a PR from `feature/api_exception_handling` to `feature/api_handling`... OK, see www.github.com\n")
+        self.assert_command(['github', 'create-pr'], expected_msg, strip_indentation=False)
+        self.assert_command(
+            ['status'],
+            """
+            develop
+            |
+            o-feature/api_handling
+              |
+              o-feature/api_exception_handling *  PR #19
+            """,
+        )
+
     git_api_state_for_test_checkout_prs = MockGithubAPIState([
         {'head': {'ref': 'chore/redundant_checks', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'restrict_access'}, 'number': '18', 'html_url': 'www.github.com', 'state': 'open'},
         {'head': {'ref': 'restrict_access', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'allow-ownership-link'}, 'number': '17', 'html_url': 'www.github.com', 'state': 'open'},