Repository: VirtusLab/git-machete
Pull request #1290 (2024-07-24 05:18:48)

Release v3.26.3


Patch:
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
index 9d758eb..09612bd 100644
--- a/ISSUE_TEMPLATE.md
+++ b/ISSUE_TEMPLATE.md
@@ -28,17 +28,25 @@ Please back-up your `.git` folder at its current state for the issue to be repro
 
 Depending on your NDAs/level of confidentiality of the project stored in your repository, you can also include **one of the 3 following variants** of diagnostics information:
 
-1. `--verbose` - executed git commands
+1. `--verbose` &mdash; executed git commands
 
     `git machete --verbose <problematic-command>` (stdout+stderr)
 
-    If it comes to repository-specific data, this will only include names of local branches and their remote counterparts. No commit messages, file paths or file contents will be included.
+    If it comes to repository-specific data, this will only include commit hashes,
+    names of local branches and their remote counterparts.
+    No commit messages, file paths or file contents will be included.
 
-2. `--debug` - above + git command outputs and other detailed logging information
+2. `--debug` &mdash; executed git commands + their outputs and other detailed logging information
 
     `git machete --debug <problematic-command>` (stdout+stderr)
 
-    If it comes to repository-specific data, this will include names of local branches and their remote counterparts, their full logs (but only wrt. commit hashes) and their full reflogs (which typically include commit messages). No file paths or file contents will be included.
+    If it comes to repository-specific data, this will include names of local branches and their remote counterparts,
+    their full logs (but only wrt. commit hashes) and their full reflogs (which typically include commit messages).
+    No file paths or file contents will be included.
+
+    Most notably, the **output of `git config --list` will be included**.
+    There is a mechanism to redact away known tokens from remote URLs &mdash; but please verify this output yourself
+    (`git config` will be among the first commands executed).
 
 3. Ultimate knowledge - above + the `.git` folder
 
@@ -48,9 +56,12 @@ Depending on your NDAs/level of confidentiality of the project stored in your re
 
     `tar czvf dotgit.tgz .git/`
 
-    Sharing contents of `.git` will provide most detailed diagnostics info, but also obviously involves sharing the entire local history of the repository. Mostly recommended for open source projects.
+    Sharing contents of `.git` will provide most detailed diagnostics info,
+    but also obviously involves sharing the entire local history of the repository. Mostly recommended for open source projects.
 
-    In case the tgz-ed repository is too large to share via GitHub web upload, you can try either pushing the tgz-ed repository as a file onto a branch, or actually sharing the entire non-tgz-ed `.git` folder as a repository on GitHub, so basically doing:
+    In case the tgz-ed repository is too large to share via GitHub web upload,
+    you can try either pushing the tgz-ed repository as a file onto a branch,
+    or actually sharing the entire non-tgz-ed `.git` folder as a repository on GitHub, so basically doing:
 
     ```shell script
     cd .git/
diff --git a/README.md b/README.md
index 490aec4..55e7e53 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,18 @@ Stable channels may lag behind; see [repology](https://repology.org/project/git-
 
 <br/>
 
+### Using Pex
+
+The [Pex tool](https://github.com/pex-tool/pex) (short for Python EXecutable) allows you to build "pex" files which are executable Python environments in a single file.
+
+Assuming you have already installed the `pex` utility, you can build git-machete as a pex:
+
+```shell_script
+pex git-machete -m git_machete.bin:main -o git-machete
+```
+
+Then put the produced `git-machete` file somewhere on your `PATH`.
+
 ## Quick start
 
 ### Discover the branch layout
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index f858029..4907009 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,11 @@
 # Release notes
 
+## New in git-machete 3.26.3
+
+- improved: performance of listing commits for red-edge branches on large repos
+- improved: message in case of missing `.git/machete` file suggests to use `git machete git{hub,lab} checkout-{prs,mrs}`
+- fixed: pass `-c log.showSignature=false` to all `git` invocations to hide GPG signatures in logs; if `log.showSignature` were set to a value equivalent to `true` in a user's `git` configuration, the GPG signatures shown in logs would cause errors in `git log` and `git reflog` parsing internal to `git machete` (reported and contributed by @goxberry)
+
 ## New in git-machete 3.26.2
 
 - fixed: parsing of multiline git config keys (reported by @saveman71)
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index e680113..30be20f 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" "Jul 03, 2024" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Jul 05, 2024" "" "git-machete"
 .SH NAME
-git-machete \- git-machete 3.26.2
+git-machete \- git-machete 3.26.3
 .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 e8fb4e0..e8867b7 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.26.2'
+__version__ = '3.26.3'
diff --git a/git_machete/client.py b/git_machete/client.py
index abe6ebf..dc7dead 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -5,6 +5,7 @@ import os
 import shlex
 import shutil
 import sys
+import textwrap
 from collections import OrderedDict
 from enum import Enum, auto
 from typing import Callable, Dict, Iterator, List, Optional, Tuple
@@ -15,9 +16,11 @@ from .code_hosting import (CodeHostingClient, CodeHostingSpec,
                            OrganizationAndRepository,
                            OrganizationAndRepositoryAndRemote, PullRequest,
                            is_matching_remote_url)
-from .constants import (DISCOVER_DEFAULT_FRESH_BRANCH_COUNT, PICK_FIRST_ROOT,
-                        PICK_LAST_ROOT, GitFormatPatterns,
-                        SquashMergeDetection, SyncToRemoteStatuses)
+from .constants import (DISCOVER_DEFAULT_FRESH_BRANCH_COUNT,
+                        INITIAL_COMMIT_COUNT_FOR_LOG, PICK_FIRST_ROOT,
+                        PICK_LAST_ROOT, TOTAL_COMMIT_COUNT_FOR_LOG,
+                        GitFormatPatterns, SquashMergeDetection,
+                        SyncToRemoteStatuses)
 from .exceptions import (InteractionStopped, MacheteException,
                          UnexpectedMacheteException)
 from .git_operations import (HEAD, AnyBranchName, AnyRevision, BranchPair,
@@ -88,9 +91,12 @@ class MacheteClient:
 
     def __raise_no_branches_error(self) -> None:
         raise MacheteException(
-            f"No branches listed in {self.__branch_layout_file_path}; use `git "
-            f"machete discover` or `git machete edit`, or edit"
-            f" {self.__branch_layout_file_path} manually.")
+            textwrap.dedent(f"""
+                No branches listed in {self.__branch_layout_file_path}. Consider one of:
+                * `git machete discover`
+                * `git machete edit` or edit {self.__branch_layout_file_path} manually
+                * `git machete github checkout-prs --mine`
+                * `git machete gitlab checkout-mrs --mine`"""[1:]))
 
     def read_branch_layout_file(self, perform_interactive_slide_out: bool, verify_branches: bool = True) -> None:
         with open(self.__branch_layout_file_path) as file:
@@ -1759,7 +1765,9 @@ class MacheteClient:
         if not branch_full_hash:
             return
 
-        for hash in self.__git.spoonfeed_log_hashes(branch_full_hash):
+        for hash in self.__git.spoonfeed_log_hashes(branch_full_hash,
+                                                    initial_count=INITIAL_COMMIT_COUNT_FOR_LOG,
+                                                    total_count=TOTAL_COMMIT_COUNT_FOR_LOG):
             if hash in self.__branch_pairs_by_hash_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
diff --git a/git_machete/constants.py b/git_machete/constants.py
index 54c6156..b5cf433 100644
--- a/git_machete/constants.py
+++ b/git_machete/constants.py
@@ -5,7 +5,8 @@ from git_machete.exceptions import MacheteException
 
 DISCOVER_DEFAULT_FRESH_BRANCH_COUNT = 10
 MAX_COMMITS_FOR_SQUASH_MERGE_DETECTION = 1000
-MAX_COUNT_FOR_INITIAL_LOG = 10
+INITIAL_COMMIT_COUNT_FOR_LOG = 10
+TOTAL_COMMIT_COUNT_FOR_LOG = 100
 
 PICK_FIRST_ROOT: int = 0
 PICK_LAST_ROOT: int = -1
diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py
index ac1c811..fca6108 100644
--- a/git_machete/git_operations.py
+++ b/git_machete/git_operations.py
@@ -9,8 +9,7 @@ from typing import (Any, Dict, Iterator, List, Match, NamedTuple, Optional,
 
 from . import utils
 from .constants import (MAX_COMMITS_FOR_SQUASH_MERGE_DETECTION,
-                        MAX_COUNT_FOR_INITIAL_LOG, GitFormatPatterns,
-                        SyncToRemoteStatuses)
+                        GitFormatPatterns, SyncToRemoteStatuses)
 from .exceptions import UnderlyingGitException, UnexpectedMacheteException
 from .utils import (AnsiEscapeCodes, CommandResult, colored, debug, fmt,
                     hex_repr)
@@ -181,6 +180,16 @@ class BranchPair(NamedTuple):
 
 HEAD = AnyRevision.of("HEAD")
 
+# For simplicity, explicitly suppress showing GPG signatures in `git log`
+# operations to simplify log parsing. This option must be passed as a
+# configuration parameter because the `git log` command's `--no-show-signature`
+# flag does not exist prior to `git` version 2.10.0; `git` does not emit an
+# error if it is passed via the `-c` flag a configuration setting that does not
+# exist, so compatibility with `git` versions earlier than version 2.10.0 is
+# preserved, even though the `log.showSignature` setting also does not exist
+# prior to version 2.10.0. Fixes a bug documented in GitHub issue #1286.
+GIT_EXEC = ("git", "-c", "log.showSignature=false")
+
 
 class GitContext:
 
@@ -225,7 +234,7 @@ class GitContext:
         self.__short_commit_hash_by_revision_cached = {}
 
     def _run_git(self, git_cmd: str, *args: str, flush_caches: bool, allow_non_zero: bool = False) -> int:
-        exit_code = utils.run_cmd("git", git_cmd, *args)
+        exit_code = utils.run_cmd(*GIT_EXEC, git_cmd, *args)
         if flush_caches:
             self.flush_caches()
         if not allow_non_zero and exit_code != 0:
@@ -235,7 +244,7 @@ class GitContext:
 
     def _popen_git(self, git_cmd: str, *args: str,
                    allow_non_zero: bool = False, env: Optional[Dict[str, str]] = None, input: Optional[str] = None) -> CommandResult:
-        exit_code, stdout, stderr = utils.popen_cmd("git", git_cmd, *args, env=env, input=input)
+        exit_code, stdout, stderr = utils.popen_cmd(*GIT_EXEC, git_cmd, *args, env=env, input=input)
         if not allow_non_zero and exit_code != 0:
             exit_code_msg: str = fmt(f"`{utils.get_cmd_shell_repr('git', git_cmd, *args, env=env)}` returned {exit_code}\n")
             stdout_msg: str = f"\n{utils.bold('stdout')}:\n{utils.dim(stdout)}" if stdout else ""
@@ -598,18 +607,20 @@ class GitContext:
         return list(map(FullCommitHash.of, utils.get_non_empty_lines(self._popen_git("log", *opts).stdout)))
 
     # 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_hashes(self, branch_full_hash: FullCommitHash) -> Iterator[FullCommitHash]:
+    # (compared to all other underlying git operations), there are two optimizations in place:
+    # 1. we first fetch only a couple of first commits in the history,
+    # 2. if these first commits aren't enough for the purpose, we fetch more, but only up to the hard limit,
+    #    to avoid time-unbounded operations on really large repos.
+    def spoonfeed_log_hashes(self, branch_full_hash: FullCommitHash, initial_count: int, total_count: int) -> Iterator[FullCommitHash]:
         if branch_full_hash not in self.__initial_log_hashes_cached:
-            self.__initial_log_hashes_cached[branch_full_hash] = self.__get_log_hashes(branch_full_hash,
-                                                                                       max_count=MAX_COUNT_FOR_INITIAL_LOG)
+            self.__initial_log_hashes_cached[branch_full_hash] = \
+                self.__get_log_hashes(branch_full_hash, max_count=initial_count)
         for hash in self.__initial_log_hashes_cached[branch_full_hash]:
             yield FullCommitHash.of(hash)
 
         if branch_full_hash not in self.__remaining_log_hashes_cached:
-            self.__remaining_log_hashes_cached[branch_full_hash] = self.__get_log_hashes(branch_full_hash,
-                                                                                         max_count=None)[MAX_COUNT_FOR_INITIAL_LOG:]
+            self.__remaining_log_hashes_cached[branch_full_hash] = \
+                self.__get_log_hashes(branch_full_hash, max_count=total_count)[initial_count:]
         for hash in self.__remaining_log_hashes_cached[branch_full_hash]:
             yield FullCommitHash.of(hash)
 
diff --git a/requirements/coverage.txt b/requirements/coverage.txt
index a1ebe97..d7b9621 100644
--- a/requirements/coverage.txt
+++ b/requirements/coverage.txt
@@ -1,6 +1,6 @@
 coverage==6.2; python_version <= '3.6'
 coverage==7.2.7; python_version >= '3.7' and python_version < '3.8'
-coverage==7.5.4; python_version >= '3.8'
+coverage==7.6.0; python_version >= '3.8'
 
 pytest-cov==4.0.0; python_version <= '3.6'
 pytest-cov==4.1.0; python_version >= '3.7' and python_version < '3.8'
diff --git a/requirements/mypy.txt b/requirements/mypy.txt
index d7699cd..dfcfaaf 100644
--- a/requirements/mypy.txt
+++ b/requirements/mypy.txt
@@ -1,3 +1,3 @@
 mypy==0.971; python_version <= '3.6'
 mypy==1.4.1; python_version >= '3.7' and python_version < '3.8'
-mypy==1.10.1; python_version >= '3.8'
+mypy==1.11.0; python_version >= '3.8'
diff --git a/requirements/pypi-deploy.txt b/requirements/pypi-deploy.txt
index 374feea..dce8fa9 100644
--- a/requirements/pypi-deploy.txt
+++ b/requirements/pypi-deploy.txt
@@ -1,3 +1,3 @@
-setuptools==70.1.1
+setuptools==71.1.0
 twine==5.1.1
 wheel==0.43.0
diff --git a/requirements/typos.txt b/requirements/typos.txt
index 0d8c671..35facb7 100644
--- a/requirements/typos.txt
+++ b/requirements/typos.txt
@@ -1,1 +1,1 @@
-typos==1.22.9
+typos==1.23.2


Test patch:
diff --git a/requirements/testenv.txt b/requirements/testenv.txt
index 375ad03..626935c 100644
--- a/requirements/testenv.txt
+++ b/requirements/testenv.txt
@@ -1,6 +1,6 @@
 pytest==7.0.1; python_version <= '3.6'
 pytest==7.4.4; python_version >= '3.7' and python_version < '3.8'
-pytest==8.2.2; python_version >= '3.8'
+pytest==8.3.1; python_version >= '3.8'
 
 pytest-mock==3.6.1; python_version <= '3.6'
 pytest-mock==3.11.1; python_version >= '3.7' and python_version < '3.8'
diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py
index 6dbed21..122256c 100644
--- a/tests/test_git_operations.py
+++ b/tests/test_git_operations.py
@@ -1,6 +1,7 @@
 
-from git_machete.git_operations import (AnyRevision, FullCommitHash,
-                                        GitContext, LocalBranchShortName)
+from git_machete.git_operations import (AnyBranchName, AnyRevision,
+                                        FullCommitHash, GitContext,
+                                        LocalBranchShortName)
 
 from .base_test import BaseTest
 
@@ -169,3 +170,20 @@ class TestGitOperations(BaseTest):
         self.repo_sandbox.write_to_file(".git/config", '[foo]\n  bar = "hello\\nworld"')
         git = GitContext()
         assert git.get_config_attr_or_none("foo.bar") == "hello\nworld"
+
+    def test_get_reflog_when_log_showsignature_is_true(self) -> None:
+        (
+            self.repo_sandbox.new_branch("master")
+                .commit("master first commit")
+                .new_branch("feature")
+                .commit("feature commit")
+                .check_out("master")
+                .commit("extra commit")
+        )
+        self.repo_sandbox.set_git_config_key("log.showSignature", "true")
+
+        git = GitContext()
+
+        # If the bug reported in GitHub issue #1286 is not fixed, this method call
+        # should raise an UnexpectedMacheteException.
+        git.get_reflog(AnyBranchName.of("feature"))
diff --git a/tests/test_go.py b/tests/test_go.py
index 9db14aa..60a8914 100644
--- a/tests/test_go.py
+++ b/tests/test_go.py
@@ -456,8 +456,10 @@ class TestGo(BaseTest):
              "the root of the current branch.")
 
     def test_go_root_no_branches(self) -> None:
-        assert_failure(
-            ["g", "root"],
-            "No branches listed in .git/machete; use git machete discover "
-            "or git machete edit, or edit .git/machete manually."
-        )
+        expected_error_message = """
+          No branches listed in .git/machete. Consider one of:
+          * git machete discover
+          * git machete edit or edit .git/machete manually
+          * git machete github checkout-prs --mine
+          * git machete gitlab checkout-mrs --mine"""
+        assert_failure(["g", "root"], expected_error_message)
diff --git a/tests/test_traverse.py b/tests/test_traverse.py
index c573e03..0f984f8 100644
--- a/tests/test_traverse.py
+++ b/tests/test_traverse.py
@@ -981,8 +981,12 @@ class TestTraverse(BaseTest):
 
     def test_traverse_no_managed_branches(self) -> None:
 
-        expected_error_message = "No branches listed in .git/machete; " \
-                                 "use git machete discover or git machete edit, or edit .git/machete manually."
+        expected_error_message = """
+          No branches listed in .git/machete. Consider one of:
+          * git machete discover
+          * git machete edit or edit .git/machete manually
+          * git machete github checkout-prs --mine
+          * git machete gitlab checkout-mrs --mine"""
         assert_failure(["traverse"], expected_error_message)
 
     def test_traverse_invalid_flag_values(self) -> None: