Repository: VirtusLab/git-machete
Pull request #1463 (2025-07-10 15:10:07)

Fix error messages for the case of missing argument in `github`, `gitlab`, `go`, `show`


Patch:
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index cec426c..ffad42b 100644
--- a/docs/man/git-machete.1
+++ b/docs/man/git-machete.1
@@ -27,7 +27,7 @@ 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 08, 2025" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Jul 10, 2025" "" "git-machete"
 .SH NAME
 git-machete \- git-machete 3.36.2
 .sp
diff --git a/git_machete/cli.py b/git_machete/cli.py
index 57e976f..e33a415 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -6,8 +6,8 @@ import os
 import pkgutil
 import sys
 import textwrap
-from typing import (Any, Dict, Iterable, List, Optional, Sequence, Tuple,
-                    TypeVar, Union)
+from typing import (Any, Dict, Iterable, List, NoReturn, Optional, Sequence,
+                    Tuple, TypeVar, Union)
 
 import git_machete.options
 from git_machete import __version__, git_config_keys, utils
@@ -143,11 +143,31 @@ def create_cli_parser() -> argparse.ArgumentParser:
     common_args_parser.add_argument('--version', action='version', version=f'git-machete version {__version__}')
     common_args_parser.add_argument('-v', '--verbose', action='store_true')
 
-    cli_parser = argparse.ArgumentParser(
-        prog='git machete',
-        argument_default=argparse.SUPPRESS,
-        add_help=False,
-        parents=[common_args_parser])
+    class CustomArgumentParser(argparse.ArgumentParser):
+        def error(self, message: str) -> NoReturn:
+            if "the following arguments are required: github subcommand" in message:
+                print(f"{message.replace(', request_id', '')}\nPossible values for subcommand are: "
+                      "anno-prs, checkout-prs, create-pr, restack-pr, retarget-pr, update-pr-descriptions, sync", file=sys.stderr)
+                self.exit(2)
+            elif "the following arguments are required: gitlab subcommand" in message:
+                print(f"{message.replace(', request_id', '')}\nPossible values for subcommand are: "
+                      "anno-mrs, checkout-mrs, create-mr, restack-mr, retarget-mr, update-mr-descriptions", file=sys.stderr)
+                self.exit(2)
+            elif "the following arguments are required: go direction" in message:
+                print(f"{message}\nPossible values for go direction are: "
+                      f"d, down, f, first, l, last, n, next, p, prev, r, root, u, up", file=sys.stderr)
+                self.exit(2)
+            elif "the following arguments are required: show direction" in message:
+                print(f"{message}\nPossible values for show direction are: "
+                      f"c, current, d, down, f, first, l, last, n, next, p, prev, r, root, u, up", file=sys.stderr)
+                self.exit(2)
+            else:
+                super().error(message)
+
+    cli_parser: argparse.ArgumentParser = CustomArgumentParser(prog='git machete',
+                                                               argument_default=argparse.SUPPRESS,
+                                                               add_help=False,
+                                                               parents=[common_args_parser])
 
     subparsers = cli_parser.add_subparsers(dest='command')
 
@@ -258,7 +278,7 @@ def create_cli_parser() -> argparse.ArgumentParser:
             usage=argparse.SUPPRESS,
             add_help=False,
             parents=[common_args_parser])
-        parser.add_argument('subcommand', choices=[
+        parser.add_argument('subcommand', metavar=f'{command} subcommand', choices=[
             f'anno-{pr_or_mr}s',
             f'checkout-{pr_or_mr}s',
             f'create-{pr_or_mr}',
@@ -288,10 +308,9 @@ def create_cli_parser() -> argparse.ArgumentParser:
         usage=argparse.SUPPRESS,
         add_help=False,
         parents=[common_args_parser])
-    go_parser.add_argument(
-        'direction',
-        choices=['d', 'down', 'f', 'first', 'l', 'last', 'n', 'next',
-                 'p', 'prev', 'r', 'root', 'u', 'up']
+    go_parser.add_argument('direction', metavar='go direction', choices=[
+        'd', 'down', 'f', 'first', 'l', 'last', 'n', 'next',
+        'p', 'prev', 'r', 'root', 'u', 'up']
     )
 
     help_parser = subparsers.add_parser(
@@ -341,10 +360,9 @@ def create_cli_parser() -> argparse.ArgumentParser:
         usage=argparse.SUPPRESS,
         add_help=False,
         parents=[common_args_parser])
-    show_parser.add_argument(
-        'direction',
-        choices=['c', 'current', 'd', 'down', 'f', 'first', 'l', 'last',
-                 'n', 'next', 'p', 'prev', 'r', 'root', 'u', 'up']
+    show_parser.add_argument('direction', metavar='show direction', choices=[
+        'c', 'current', 'd', 'down', 'f', 'first', 'l', 'last',
+        'n', 'next', 'p', 'prev', 'r', 'root', 'u', 'up']
     )
     show_parser.add_argument('branch', nargs='?', default=argparse.SUPPRESS)
 


Test patch:
diff --git a/tests/mockers.py b/tests/mockers.py
index ab718bc..a122d10 100644
--- a/tests/mockers.py
+++ b/tests/mockers.py
@@ -7,7 +7,7 @@ import textwrap
 import time
 from contextlib import (AbstractContextManager, contextmanager,
                         redirect_stderr, redirect_stdout)
-from typing import Any, Callable, Iterable, Iterator, Tuple, Type
+from typing import Any, Callable, Iterable, Iterator, Optional, Tuple, Type
 
 import pytest
 
@@ -38,6 +38,24 @@ def fixed_author_and_committer_date_in_past() -> AbstractContextManager:  # type
     )
 
 
+def launch_command_capturing_output_and_exception(*cmd_and_args: str) -> Tuple[Optional[str], Optional[BaseException]]:
+    with io.StringIO() as out:
+        try:
+            with redirect_stdout(out):
+                with redirect_stderr(out):
+                    utils.displayed_warnings = set()
+                    cli.launch(list(cmd_and_args))
+                output = out.getvalue()
+                if sys.platform == 'win32':
+                    output = output.replace('.git\\machete', '.git/machete')
+                return output, None
+        except BaseException as e:
+            output = out.getvalue()
+            if sys.platform == 'win32':
+                output = output.replace('.git\\machete', '.git/machete')
+            return output, e
+
+
 def launch_command(*cmd_and_args: str) -> str:
     with io.StringIO() as out:
         with redirect_stdout(out):
diff --git a/tests/test_advance.py b/tests/test_advance.py
index 2c9e9f0..2d92007 100644
--- a/tests/test_advance.py
+++ b/tests/test_advance.py
@@ -1,10 +1,10 @@
-import pytest
 from pytest_mock import MockerFixture
 
-from git_machete.exceptions import UnderlyingGitException
+from git_machete.exceptions import ExitCode, UnderlyingGitException
 
 from .base_test import BaseTest
 from .mockers import (assert_failure, assert_success, launch_command,
+                      launch_command_capturing_output_and_exception,
                       mock_input_returning, mock_input_returning_y,
                       rewrite_branch_layout_file)
 from .mockers_git_repository import (check_out, commit, create_repo,
@@ -169,14 +169,14 @@ class TestAdvance(BaseTest):
         assert_failure(['advance', '-y'], expected_error_message)
 
         self.patch_symbol(mocker, "builtins.input", mock_input_returning(""))
-        with pytest.raises(SystemExit) as e:
-            assert launch_command("advance")
-        assert e.value.code == 0
+        output, e = launch_command_capturing_output_and_exception("advance")
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.SUCCESS
 
         self.patch_symbol(mocker, "builtins.input", mock_input_returning("not-a-valid-number"))
-        with pytest.raises(SystemExit) as e:
-            assert launch_command("advance")
-        assert e.value.code == 1
+        output, e = launch_command_capturing_output_and_exception("advance")
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.MACHETE_EXCEPTION
 
         self.patch_symbol(mocker, "builtins.input", mock_input_returning("3"))
         assert_failure(["advance"], "Invalid index: 3")
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0763836..fb92958 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -15,19 +15,19 @@ class TestCLI(BaseTest):
         assert len(alias_by_command.values()) == len(set(alias_by_command.values()))
 
     def test_main(self, mocker: MockerFixture) -> None:
-        with pytest.raises(SystemExit) as e:
+        with pytest.raises(SystemExit) as ei:
             self.patch_symbol(mocker, "sys.argv", ["go", "no-such-direction"])
             main()
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        assert ExitCode.ARGUMENT_ERROR == ei.value.code
 
-        with pytest.raises(SystemExit) as e:
+        with pytest.raises(SystemExit) as ei:
             self.patch_symbol(mocker, "sys.argv", ["", "status", "--", "--patch"])
             main()
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        assert ExitCode.ARGUMENT_ERROR == ei.value.code
 
         os.chdir(mkdtemp())
 
-        with pytest.raises(SystemExit) as e:
+        with pytest.raises(SystemExit) as ei:
             self.patch_symbol(mocker, "sys.argv", ["", "file"])
             main()
-        assert ExitCode.MACHETE_EXCEPTION == e.value.code
+        assert ExitCode.MACHETE_EXCEPTION == ei.value.code
diff --git a/tests/test_github.py b/tests/test_github.py
index d279f8d..06d8775 100644
--- a/tests/test_github.py
+++ b/tests/test_github.py
@@ -7,9 +7,11 @@ from unittest.mock import mock_open
 from pytest_mock import MockerFixture
 
 from git_machete.code_hosting import OrganizationAndRepository
+from git_machete.exceptions import ExitCode
 from git_machete.github import GitHubClient, GitHubToken
 from tests.base_test import BaseTest
 from tests.mockers import (assert_failure, assert_success, launch_command,
+                           launch_command_capturing_output_and_exception,
                            mock__popen_cmd_with_fixed_results,
                            mock_input_returning_y, overridden_environment,
                            rewrite_branch_layout_file)
@@ -371,3 +373,11 @@ class TestGitHub(BaseTest):
                        "of the following options: --all, --by=..., --mine, --related")
         assert_failure(["github", "update-pr-descriptions", "--update-related-descriptions"],
                        "--update-related-descriptions option is only valid with create-pr, restack-pr and retarget-pr subcommands.")
+
+    def test_github_missing_direction(self) -> None:
+        output, e = launch_command_capturing_output_and_exception("github")
+        assert output == \
+            "the following arguments are required: github subcommand\n" \
+            "Possible values for subcommand are: anno-prs, checkout-prs, create-pr, restack-pr, retarget-pr, update-pr-descriptions, sync\n"
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py
index 133e13e..be76118 100644
--- a/tests/test_gitlab.py
+++ b/tests/test_gitlab.py
@@ -6,9 +6,11 @@ from unittest.mock import mock_open
 from pytest_mock import MockerFixture
 
 from git_machete.code_hosting import OrganizationAndRepository
+from git_machete.exceptions import ExitCode
 from git_machete.gitlab import GitLabClient, GitLabToken
 from tests.base_test import BaseTest
 from tests.mockers import (assert_failure, assert_success, launch_command,
+                           launch_command_capturing_output_and_exception,
                            mock__popen_cmd_with_fixed_results,
                            mock_input_returning_y, overridden_environment,
                            rewrite_branch_layout_file)
@@ -322,3 +324,11 @@ class TestGitLab(BaseTest):
                        "of the following options: --all, --by=..., --mine, --related")
         assert_failure(["gitlab", "update-mr-descriptions", "--update-related-descriptions"],
                        "--update-related-descriptions option is only valid with create-mr, restack-mr and retarget-mr subcommands.")
+
+    def test_gitlab_missing_direction(self) -> None:
+        output, e = launch_command_capturing_output_and_exception("gitlab")
+        assert output == \
+            "the following arguments are required: gitlab subcommand\n" \
+            "Possible values for subcommand are: anno-mrs, checkout-mrs, create-mr, restack-mr, retarget-mr, update-mr-descriptions\n"
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
diff --git a/tests/test_go.py b/tests/test_go.py
index 716e80b..63a3758 100644
--- a/tests/test_go.py
+++ b/tests/test_go.py
@@ -1,10 +1,10 @@
-import pytest
 from pytest_mock import MockerFixture
 
 from git_machete.exceptions import ExitCode
 
 from .base_test import BaseTest
 from .mockers import (assert_failure, assert_success, launch_command,
+                      launch_command_capturing_output_and_exception,
                       mock_input_returning, rewrite_branch_layout_file)
 from .mockers_git_repository import (check_out, commit, create_repo,
                                      new_branch, new_orphan_branch)
@@ -17,18 +17,18 @@ class TestGo(BaseTest):
         new_branch("level-0-branch")
         commit()
 
-        with pytest.raises(SystemExit) as e:
-            launch_command("go", "current")
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        output, e = launch_command_capturing_output_and_exception("go", "current")
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
 
     def test_go_invalid_direction(self) -> None:
         create_repo()
         new_branch("level-0-branch")
         commit()
 
-        with pytest.raises(SystemExit) as e:
-            launch_command("go", "invalid")
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        output, e = launch_command_capturing_output_and_exception("go", "invalid")
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
 
     def test_go_up(self) -> None:
         """Verify behaviour of a 'git machete go up' command.
@@ -462,3 +462,11 @@ class TestGo(BaseTest):
           * git machete github checkout-prs --mine
           * git machete gitlab checkout-mrs --mine"""
         assert_failure(["g", "root"], expected_error_message)
+
+    def test_go_missing_direction(self) -> None:
+        output, e = launch_command_capturing_output_and_exception("go")
+        assert output == \
+            "the following arguments are required: go direction\n" \
+            "Possible values for go direction are: d, down, f, first, l, last, n, next, p, prev, r, root, u, up\n"
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
diff --git a/tests/test_help.py b/tests/test_help.py
index 11bc8c4..6aca64f 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -1,27 +1,26 @@
 from typing import List
 
-import pytest
-
 from git_machete.cli import commands_and_aliases
 from git_machete.exceptions import ExitCode
 
 from .base_test import BaseTest
-from .mockers import launch_command
+from .mockers import (launch_command,
+                      launch_command_capturing_output_and_exception)
 from .mockers_git_repository import create_repo, set_git_config_key
 
 
 class TestHelp(BaseTest):
 
     def test_help(self) -> None:
-        with pytest.raises(SystemExit) as e:
-            launch_command()
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        output, e = launch_command_capturing_output_and_exception()
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
 
         assert "--verbose" in launch_command("help")
 
-        with pytest.raises(SystemExit) as e:
-            launch_command("help", "no-such-command")
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        output, e = launch_command_capturing_output_and_exception("help", "no-such-command")
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
 
         help_topics: List[str] = ['config', 'format', 'hooks']
 
@@ -30,13 +29,15 @@ class TestHelp(BaseTest):
             launch_command("help", command)
 
             if command not in help_topics:
-                with pytest.raises(SystemExit) as e:
-                    assert "Usage:" in launch_command(command, "--help")
-                assert ExitCode.SUCCESS == e.value.code
+                output, e = launch_command_capturing_output_and_exception(command, "--help")
+                assert output is not None
+                assert "Usage:" in output
+                assert type(e) is SystemExit
+                assert e.code == ExitCode.SUCCESS
             else:
-                with pytest.raises(SystemExit) as e:
-                    launch_command(command, "--help")
-                assert ExitCode.ARGUMENT_ERROR == e.value.code
+                output, e = launch_command_capturing_output_and_exception(command, "--help")
+                assert type(e) is SystemExit
+                assert e.code == ExitCode.ARGUMENT_ERROR
 
     def test_help_output_has_no_ansi_codes(self) -> None:
         for command in commands_and_aliases:
diff --git a/tests/test_list.py b/tests/test_list.py
index 67544d3..3375625 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -1,10 +1,10 @@
 
-import pytest
 
 from git_machete.exceptions import ExitCode
 
 from .base_test import BaseTest
 from .mockers import (assert_failure, assert_success, launch_command,
+                      launch_command_capturing_output_and_exception,
                       rewrite_branch_layout_file)
 from .mockers_git_repository import (check_out, commit,
                                      create_repo_with_remote, delete_branch,
@@ -133,9 +133,9 @@ class TestList(BaseTest):
             ""
         )
 
-        with pytest.raises(SystemExit) as e:
-            launch_command('list', 'no-such-category')
-        assert ExitCode.ARGUMENT_ERROR == e.value.code
+        output, e = launch_command_capturing_output_and_exception('list', 'no-such-category')
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR
 
     def test_list_invalid_flag_combinations(self) -> None:
         assert_failure(["list", "slidable-after"], "git machete list slidable-after requires an extra <branch> argument")
diff --git a/tests/test_show.py b/tests/test_show.py
index 7e60068..ab1b417 100644
--- a/tests/test_show.py
+++ b/tests/test_show.py
@@ -1,7 +1,10 @@
 import os
 
+from git_machete.exceptions import ExitCode
+
 from .base_test import BaseTest
 from .mockers import (assert_failure, assert_success, execute, launch_command,
+                      launch_command_capturing_output_and_exception,
                       remove_directory, rewrite_branch_layout_file, sleep)
 from .mockers_git_repository import (amend_commit, check_out, commit,
                                      create_repo, create_repo_with_remote,
@@ -383,3 +386,11 @@ class TestShow(BaseTest):
         assert 'level-0-branch' == launch_command("show", "r").strip(), \
             ("Verify that 'git machete show r' displays name of the root of "
              "the current branch.")
+
+    def test_show_missing_direction(self) -> None:
+        output, e = launch_command_capturing_output_and_exception("show")
+        assert output == \
+            "the following arguments are required: show direction\n" \
+            "Possible values for show direction are: c, current, d, down, f, first, l, last, n, next, p, prev, r, root, u, up\n"
+        assert type(e) is SystemExit
+        assert e.code == ExitCode.ARGUMENT_ERROR