Repository: VirtusLab/git-machete
Pull request #1128 (2023-12-07 11:55:07)

Release v3.21.1


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6f26c60..24953c6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -45,7 +45,7 @@ jobs:
 
             sudo apt-get install zsh
 
-            pip3 install tox
+            pip3 install tox typos
 
             # For enforce-shell-scripts-pass-shellcheck.sh
             sudo apt-get install shellcheck
diff --git a/.typos.toml b/.typos.toml
new file mode 100644
index 0000000..a473001
--- /dev/null
+++ b/.typos.toml
@@ -0,0 +1,8 @@
+[files]
+extend-exclude = [
+  "graphics/*.cast",
+  "graphics/generate-asciinema-gif",
+]
+
+[default.extend-words]
+ther = "ther"  # to allow for `o[ther]`
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 809094b..32b6d07 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,10 @@
 # Release notes
 
+## New in git-machete 3.21.1
+
+- fixed: `Cannot parse Link header` error in `git machete github` subcommands when there are more than 100 PRs in the given repository (reported by @domesticsimian)
+- fixed: if `.git/machete` doesn't exist, `git machete add <branch>` adds both current branch and the newly-added `<branch>` (not just the latter)
+
 ## New in git-machete 3.21.0
 
 - added: `--removed-from-remote` flag to `git machete slide-out` (contributed by @raylu)
diff --git a/ci/checks/prohibit-typos.sh b/ci/checks/prohibit-typos.sh
new file mode 100755
index 0000000..4439ef2
--- /dev/null
+++ b/ci/checks/prohibit-typos.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+# See https://github.com/crate-ci/typos
+typos --ignore --format brief
diff --git a/ci/checks/run-all-checks.sh b/ci/checks/run-all-checks.sh
index 536aedd..5c3d557 100755
--- a/ci/checks/run-all-checks.sh
+++ b/ci/checks/run-all-checks.sh
@@ -36,3 +36,6 @@ prohibit-strings-with-backslash-continuation.sh
 prohibit-strings-with-useless-interpolations.sh
 prohibit-tab-character.sh
 prohibit-trailing-whitespace.sh
+if command -v typos &>/dev/null; then
+  prohibit-typos.sh
+fi
diff --git a/ci/deb-ppa-upload/README.md b/ci/deb-ppa-upload/README.md
index 64e3c16..a70a513 100644
--- a/ci/deb-ppa-upload/README.md
+++ b/ci/deb-ppa-upload/README.md
@@ -16,7 +16,7 @@ gpg --default-new-key-algo rsa4096 --gen-key
 # Specify a password, add the password to password manager and CI (GPG_PRIVATE_KEY_PASSPHRASE env var).
 
 gpg --armor --export-secret-key <key-id> | pbcopy
-# Add to password manager (directly) & CI (base64-endcoded, GPG_PRIVATE_KEY_CONTENTS_BASE64 env var).
+# Add to password manager (directly) & CI (base64-encoded, GPG_PRIVATE_KEY_CONTENTS_BASE64 env var).
 
 gpg --send-keys --keyserver keyserver.ubuntu.com <key-id>
 # Check if the key has been uploaded correctly via https://keyserver.ubuntu.com/.
diff --git a/docs/generate_py_docs.py b/docs/generate_py_docs.py
index d270a96..de15be4 100644
--- a/docs/generate_py_docs.py
+++ b/docs/generate_py_docs.py
@@ -138,7 +138,7 @@ def html2txt(html_: str):
     return text
 
 
-def skip_or_replace_unparseable_directives(rst_: str) -> str:
+def skip_or_replace_unparsable_directives(rst_: str) -> str:
     rst_ = rst_.replace(':ref:', '')
     return rst_
 
@@ -196,7 +196,7 @@ if __name__ == '__main__':
     short_docs_rst_file = docs_source_path + '/short_docs.rst'
     with open(short_docs_rst_file, 'r') as f:
         rst = f.read()
-    rst = skip_or_replace_unparseable_directives(rst)
+    rst = skip_or_replace_unparsable_directives(rst)
     html = rst2html(rst)
     plain_text = html2txt(html)
     plain_text = skip_prefix_new_lines(plain_text)
@@ -214,7 +214,7 @@ if __name__ == '__main__':
         with open(file, 'r') as f:
             rst = f.read()
 
-        rst = skip_or_replace_unparseable_directives(rst)
+        rst = skip_or_replace_unparsable_directives(rst)
         rst = resolve_includes(rst_=rst, docs_source_path_=docs_source_path)
 
         html = rst2html(rst)
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index e12386e..4505dc7 100644
--- a/docs/man/git-machete.1
+++ b/docs/man/git-machete.1
@@ -27,9 +27,9 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
-.TH "GIT-MACHETE" "1" "Nov 30, 2023" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Dec 07, 2023" "" "git-machete"
 .SH NAME
-git-machete \- git-machete 3.21.0
+git-machete \- git-machete 3.21.1
 .sp
 git machete is a robust tool that \fBsimplifies your git workflows\fP\&.
 .sp
diff --git a/git_machete/__init__.py b/git_machete/__init__.py
index 64b38b0..9ad8de9 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.21.0'
+__version__ = '3.21.1'
diff --git a/git_machete/client.py b/git_machete/client.py
index aa8a21d..f6b7182 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -269,10 +269,21 @@ class MacheteClient:
                 if self.ask_if(msg, opt_yes_msg, opt_yes=opt_yes, verbose=verbose) in ('y', 'yes'):
                     # If `--onto` hasn't been explicitly specified, let's try to
                     # assess if the current branch would be a good `onto`.
-                    if self.__roots and not opt_onto:
+                    if not opt_onto:
                         current_branch = self.__git.get_current_branch_or_none()
-                        if current_branch and current_branch in self.managed_branches:
-                            opt_onto = current_branch
+                        if self.__roots:
+                            if current_branch and current_branch in self.managed_branches:
+                                opt_onto = current_branch
+                        else:
+                            if current_branch:
+                                # In this case (empty .git/machete, creating a new branch with `git machete add`)
+                                # it's usually pretty obvious that the current branch needs to be added as root first.
+                                # Let's skip interactive questions so as not to confuse new users.
+                                self.__roots = [current_branch]
+                                self.__managed_branches = [current_branch]
+                                if verbose:
+                                    print(fmt(f"Added branch {bold(current_branch)} as a new root"))
+                                opt_onto = current_branch
                     self.__git.create_branch(branch, out_of, switch_head=switch_head_if_new_branch)
                 else:
                     return
diff --git a/git_machete/github.py b/git_machete/github.py
index bec5f06..aeb6f7b 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -252,10 +252,10 @@ class GitHubClient:
                     match = re.search(f'<{url_prefix_regex}(/[^>]+)>; rel="next"', link_header)
                     if match:
                         next_page_path = match.group(1)
-                        debug(f'there is more data to retrieve under {next_page_path}')
+                        debug(f'link header is present in the response, and there is more data to retrieve under {next_page_path}')
                         return parsed_response_body + self.__fire_github_api_request(method, next_page_path, request_body)
                     else:
-                        raise UnexpectedMacheteException(f"Cannot parse Link header: `{link_header}`.")
+                        debug('link header is present in the response, but there is no more data to retrieve')
                 return parsed_response_body
         except urllib.error.HTTPError as err:
             if err.code == http.HTTPStatus.UNPROCESSABLE_ENTITY:


Test patch:
diff --git a/tests/mockers_github.py b/tests/mockers_github.py
index 0e1860e..915b6ff 100644
--- a/tests/mockers_github.py
+++ b/tests/mockers_github.py
@@ -136,6 +136,10 @@ def __mock_urlopen_impl(github_api_state: MockGitHubAPIState, request: Request)
         regex = pattern.replace('*', '[^/]+')
         return re.match('^(/api/v3)?' + regex + '$', parsed_url.path) is not None
 
+    def url_with_query_params(**new_params: Any) -> str:
+        new_query_string: str = urlencode({**query_params, **new_params})
+        return parsed_url._replace(query=new_query_string).geturl()
+
     def handle_get() -> "MockGitHubAPIResponse":
         if url_path_matches('/repositories/[0-9]+'):
             return MockGitHubAPIResponse(HTTPStatus.OK, {"full_name": "example-org/example-repo"})
@@ -154,13 +158,12 @@ def __mock_urlopen_impl(github_api_state: MockGitHubAPIState, request: Request)
                 start = (page - 1) * per_page
                 end = page * per_page
                 if end < len(pulls):
-                    new_query_params: Dict[str, Any] = {**query_params, 'page': page + 1}
-                    new_query_string: str = urlencode(new_query_params)
-                    new_url: str = parsed_url._replace(query=new_query_string).geturl()
-                    link_header = f'<{new_url}>; rel="next"'
-                else:
-                    link_header = ''
-                return MockGitHubAPIResponse(HTTPStatus.OK, response_data=pulls[start:end], headers={'link': link_header})
+                    headers = {'link': f'<{url_with_query_params(page=page + 1)}>; rel="next"'}
+                elif page == 1:  # we're at the first page, and there are no more pages
+                    headers = {}
+                else:  # we're at the final page, and there were some pages before
+                    headers = {'link': f'<{url_with_query_params(page=1)}>; rel="first"'}
+                return MockGitHubAPIResponse(HTTPStatus.OK, response_data=pulls[start:end], headers=headers)
         elif url_path_matches('/repos/*/*/pulls/[0-9]+'):
             pull_no = int(url_segments[-1])
             pull = github_api_state.get_pull_by_number(pull_no)
diff --git a/tests/test_add.py b/tests/test_add.py
index 3eb5950..b97b98f 100644
--- a/tests/test_add.py
+++ b/tests/test_add.py
@@ -2,7 +2,8 @@ from pytest_mock import MockerFixture
 
 from .base_test import BaseTest
 from .mockers import (assert_failure, assert_success, mock_input_returning,
-                      mock_input_returning_y, rewrite_branch_layout_file)
+                      mock_input_returning_y, read_branch_layout_file,
+                      rewrite_branch_layout_file)
 
 
 class TestAdd(BaseTest):
@@ -87,7 +88,8 @@ class TestAdd(BaseTest):
         assert_success(
             ['add', '-y', 'foo'],
             'A local branch foo does not exist. Creating out of the current HEAD\n'
-            'Added branch foo as a new root\n'
+            'Added branch master as a new root\n'
+            'Added branch foo onto master\n'
         )
 
         self.patch_symbol(mocker, "builtins.input", mock_input_returning("n"))
@@ -169,6 +171,30 @@ class TestAdd(BaseTest):
             "Use git machete add foo or git machete edit."
         )
 
+    def test_add_new_branch_onto_master_for_fresh_start_with_yes(self) -> None:
+        self.repo_sandbox.new_branch("master").commit("master commit.")
+        assert_success(
+            ['add', '--yes', 'foo'],
+            """
+            A local branch foo does not exist. Creating out of the current HEAD
+            Added branch master as a new root
+            Added branch foo onto master
+            """)
+        assert read_branch_layout_file() == "master\n  foo\n"
+
+    def test_add_new_branch_onto_master_for_fresh_start_without_yes(self, mocker: MockerFixture) -> None:
+        self.repo_sandbox.new_branch("master").commit("master commit.")
+
+        self.patch_symbol(mocker, "builtins.input", mock_input_returning_y)
+        assert_success(
+            ['add', 'foo'],
+            """
+            A local branch foo does not exist. Create out of the current HEAD? (y, N)
+            Added branch master as a new root
+            Added branch foo onto master
+            """)
+        assert read_branch_layout_file() == "master\n  foo\n"
+
     def test_add_as_root_with_onto(self) -> None:
         assert_failure(
             ['add', '--onto', 'foo', '--as-root'],
diff --git a/tests/test_github_retarget_pr.py b/tests/test_github_retarget_pr.py
index c3b91ef..f0eae90 100644
--- a/tests/test_github_retarget_pr.py
+++ b/tests/test_github_retarget_pr.py
@@ -115,14 +115,14 @@ class TestGitHubRetargetPR(BaseTest):
         self.patch_symbol(mocker, 'urllib.request.urlopen',
                           mock_urlopen(self.github_api_state_for_test_github_retarget_pr_explicit_branch()))
 
-        branchs_first_commit_msg = "First commit on branch."
-        branchs_second_commit_msg = "Second commit on branch."
+        branch_first_commit_msg = "First commit on branch."
+        branch_second_commit_msg = "Second commit on branch."
         (
             self.repo_sandbox.new_branch("root")
                 .commit("First commit on root.")
                 .new_branch("branch-1")
-                .commit(branchs_first_commit_msg)
-                .commit(branchs_second_commit_msg)
+                .commit(branch_first_commit_msg)
+                .commit(branch_second_commit_msg)
                 .push()
                 .new_branch('feature')
                 .commit('introduce feature')
@@ -191,8 +191,8 @@ class TestGitHubRetargetPR(BaseTest):
         github_api_state = self.github_api_state_for_test_retarget_pr()
         self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(github_api_state))
 
-        branchs_first_commit_msg = "First commit on branch."
-        branchs_second_commit_msg = "Second commit on branch."
+        branch_first_commit_msg = "First commit on branch."
+        branch_second_commit_msg = "Second commit on branch."
 
         origin_1_remote_path = mkdtemp()
         origin_2_remote_path = mkdtemp()
@@ -209,8 +209,8 @@ class TestGitHubRetargetPR(BaseTest):
                 .push(remote='origin_1')
                 .push(remote='origin_2')
                 .new_branch("branch-1")
-                .commit(branchs_first_commit_msg)
-                .commit(branchs_second_commit_msg)
+                .commit(branch_first_commit_msg)
+                .commit(branch_second_commit_msg)
                 .push(remote='origin_1')
                 .push(remote='origin_2')
                 .new_branch('feature')
diff --git a/tests/test_log.py b/tests/test_log.py
index 92e4ecd..726c3bb 100644
--- a/tests/test_log.py
+++ b/tests/test_log.py
@@ -15,16 +15,16 @@ class TestLog(BaseTest):
 
         self.repo_sandbox.new_branch('child')
         self.repo_sandbox.commit()
-        childs_first_commit_hash = self.repo_sandbox.get_current_commit_hash()
+        child_first_commit_hash = self.repo_sandbox.get_current_commit_hash()
         self.repo_sandbox.commit()
-        childs_second_commit_hash = self.repo_sandbox.get_current_commit_hash()
+        child_second_commit_hash = self.repo_sandbox.get_current_commit_hash()
 
         log_contents = [launch_command('log'), launch_command('log', 'child'), launch_command('log', 'refs/heads/child')]
 
-        assert all(childs_first_commit_hash in log_content for log_content in log_contents), \
+        assert all(child_first_commit_hash in log_content for log_content in log_contents), \
             ("Verify that oldest commit from current branch is visible when "
              "executing `git machete log`.")
-        assert all(childs_second_commit_hash in log_content for log_content in log_contents), \
+        assert all(child_second_commit_hash in log_content for log_content in log_contents), \
             ("Verify that youngest commit from current branch is visible when "
              "executing `git machete log`.")
         assert all(roots_only_commit_hash not in log_content for log_content in log_contents), \
diff --git a/tests/test_update.py b/tests/test_update.py
index 3f62438..785ef55 100644
--- a/tests/test_update.py
+++ b/tests/test_update.py
@@ -125,14 +125,14 @@ class TestUpdate(BaseTest):
         (included) fork point specified by the option '-f'.
         """
 
-        branchs_first_commit_msg = "First commit on branch."
-        branchs_second_commit_msg = "Second commit on branch."
+        branch_first_commit_msg = "First commit on branch."
+        branch_second_commit_msg = "Second commit on branch."
         (
             self.repo_sandbox.new_branch("root")
             .commit("First commit on root.")
             .new_branch("branch-1")
-            .commit(branchs_first_commit_msg)
-            .commit(branchs_second_commit_msg)
+            .commit(branch_first_commit_msg)
+            .commit(branch_second_commit_msg)
         )
         branch_second_commit_hash = self.repo_sandbox.get_current_commit_hash()
         (
@@ -157,11 +157,11 @@ class TestUpdate(BaseTest):
         assert roots_second_commit_hash == new_fork_point_hash, \
             ("Verify that 'git machete update --no-interactive-rebase -f "
              "<commit_hash>' performs 'git rebase' to the upstream branch.")
-        assert branchs_first_commit_msg not in branch_history, \
+        assert branch_first_commit_msg not in branch_history, \
             ("Verify that 'git machete update --no-interactive-rebase -f "
              "<commit_hash>' drops the commits until (included) fork point "
              "specified by the option '-f' from the current branch.")
-        assert branchs_second_commit_msg not in branch_history, \
+        assert branch_second_commit_msg not in branch_history, \
             ("Verify that 'git machete update --no-interactive-rebase -f "
              "<commit_hash>' drops the commits until (included) fork point "
              "specified by the option '-f' from the current branch.")