diff --git a/commitizen/cli.py b/commitizen/cli.py index 7f4d4893c0..212c89b6f7 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -15,6 +15,7 @@ CommitizenException, ExitCode, ExpectedExit, + InvalidCommandArgumentError, NoCommandFoundError, ) @@ -441,7 +442,7 @@ def main(): # This is for the command required constraint in 2.0 try: - args = parser.parse_args() + args, unknown_args = parser.parse_known_args() except (TypeError, SystemExit) as e: # https://github.com/commitizen-tools/commitizen/issues/429 # argparse raises TypeError when non exist command is provided on Python < 3.9 @@ -450,6 +451,28 @@ def main(): raise NoCommandFoundError() raise e + arguments = vars(args) + if unknown_args: + # Raise error for extra-args without -- separation + if "--" not in unknown_args: + raise InvalidCommandArgumentError( + f"Invalid commitizen arguments were found: `{' '.join(unknown_args)}`. " + "Please use -- separator for extra git args" + ) + # Raise error for extra-args before -- + elif unknown_args[0] != "--": + pos = unknown_args.index("--") + raise InvalidCommandArgumentError( + f"Invalid commitizen arguments were found before -- separator: `{' '.join(unknown_args[:pos])}`. " + ) + # Log warning for -- without any extra args + elif len(unknown_args) == 1: + logger.warning( + "\nWARN: Incomplete commit command: received -- separator without any following git arguments\n" + ) + extra_args = " ".join(unknown_args[1:]) + arguments["extra_cli_args"] = extra_args + if args.name: conf.update({"name": args.name}) elif not args.name and not conf.path: @@ -465,7 +488,7 @@ def main(): ) sys.excepthook = no_raise_debug_excepthook - args.func(conf, vars(args))() + args.func(conf, arguments)() if __name__ == "__main__": diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 39cab33b5c..adb630af86 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -98,9 +98,14 @@ def __call__(self): ) if signoff: - c = git.commit(m, "-s") + out.warn( + "signoff mechanic is deprecated, please use `cz commit -- -s` instead." + ) + extra_args = self.arguments.get("extra_cli_args", "--") + " -s" else: - c = git.commit(m) + extra_args = self.arguments.get("extra_cli_args", "") + + c = git.commit(m, args=extra_args) if c.return_code != 0: out.error(c.err) diff --git a/commitizen/git.py b/commitizen/git.py index 67652b9516..46aa3abffc 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -98,7 +98,9 @@ def add(args: str = "") -> cmd.Command: def commit( - message: str, args: str = "", committer_date: str | None = None + message: str, + args: str = "", + committer_date: str | None = None, ) -> cmd.Command: f = NamedTemporaryFile("wb", delete=False) f.write(message.encode("utf-8")) diff --git a/docs/bump.md b/docs/bump.md index 287d4e1e35..e58a11e18d 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -323,7 +323,7 @@ These are used in: * `cz bump`: Find previous release tag (exact match) and generate new tag. * Find previous release tags in `cz changelog`. - * If `--incremental`: Using latest version found in the changelog, scan existing Git tags with 89\% similarity match. + * If `--incremental`: Using latest version found in the changelog, scan existing Git tags with 89\% similarity match. * `--rev-range` is converted to Git tag names with `tag_format` before searching Git history. * If the `scm` `version_provider` is used, it uses different regexes to find the previous version tags: * If `tag_format` is set to `$version` (default): `VersionProtocol.parser` (allows `v` prefix) diff --git a/docs/commit.md b/docs/commit.md index ab6038946c..2215e0d805 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -4,28 +4,28 @@ In your terminal run `cz commit` or the shortcut `cz c` to generate a guided git commit. -A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`. - You can run `cz commit --write-message-to-file COMMIT_MSG_FILE` to additionally save the generated message to a file. This can be combined with the `--dry-run` flag to only write the message to a file and not modify files and create a commit. A possible use case for this is to [automatically prepare a commit message](./tutorials/auto_prepare_commit_message.md). -!!! note - To maintain platform compatibility, the `commit` command disables ANSI escaping in its output. - In particular, pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417). - -## Configuration -### `always_signoff` +!!! note + To maintain platform compatibility, the `commit` command disable ANSI escaping in its output. + In particular pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417). -When set to `true`, each commit message created by `cz commit` will be signed off. -Defaults to: `false`. +### git options -In your `pyproject.toml` or `.cz.toml`: +`git` command options that are not implemented by commitizen can be use via the `--` syntax for the `commit` command. +The syntax separates commitizen arguments from `git commit` arguments by a double dash. This is the resulting syntax: +```sh +cz commit -- -```toml -[tool.commitizen] -always_signoff = true +# e.g., cz commit --dry-run -- -a -S ``` +For example, using the `-S` option on `git commit` to sign a commit is now commitizen compatible: `cz c -- -S` + +!!! note + Deprecation warning: A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`. + This syntax is now deprecated in favor of the new `cz commit -- -s` syntax. diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index b48ac9d0ed..e3f9989823 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -88,7 +88,7 @@ def test_commit_retry_works(config, mocker: MockFixture): commands.Commit(config, {"retry": True})() - commit_mock.assert_called_with("feat: user created\n\ncloses #21") + commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") prompt_mock.assert_called_once() success_mock.assert_called_once() assert not os.path.isfile(temp_file) @@ -174,7 +174,7 @@ def test_commit_command_with_signoff_option(config, mocker: MockFixture): commands.Commit(config, {"signoff": True})() - commit_mock.assert_called_once_with(ANY, "-s") + commit_mock.assert_called_once_with(ANY, args="-- -s") success_mock.assert_called_once() @@ -197,7 +197,7 @@ def test_commit_command_with_always_signoff_enabled(config, mocker: MockFixture) config.settings["always_signoff"] = True commands.Commit(config, {})() - commit_mock.assert_called_once_with(ANY, "-s") + commit_mock.assert_called_once_with(ANY, args="-- -s") success_mock.assert_called_once() @@ -276,3 +276,23 @@ def test_commit_command_with_all_option(config, mocker: MockFixture): commands.Commit(config, {"all": True})() add_mock.assert_called() success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_extra_args(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + commands.Commit(config, {"extra_cli_args": "-- -extra-args1 -extra-arg2"})() + commit_mock.assert_called_once_with(ANY, args="-- -extra-args1 -extra-arg2") + success_mock.assert_called_once() diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d53ed7ba2..93f6c16ddd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,12 @@ from pytest_mock import MockFixture from commitizen import cli -from commitizen.exceptions import ExpectedExit, NoCommandFoundError, NotAGitProjectError +from commitizen.exceptions import ( + ExpectedExit, + NoCommandFoundError, + NotAGitProjectError, + InvalidCommandArgumentError, +) def test_sysexit_no_argv(mocker: MockFixture, capsys): @@ -149,3 +154,21 @@ def test_parse_no_raise_mix_invalid_arg_is_skipped(): input_str = "NO_COMMITIZEN_FOUND,2,nothing,4" result = cli.parse_no_raise(input_str) assert result == [1, 2, 4] + + +def test_unknown_args_raises(mocker: MockFixture): + testargs = ["cz", "c", "-this_arg_is_not_supported"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(InvalidCommandArgumentError) as excinfo: + cli.main() + assert "Invalid commitizen arguments were found" in str(excinfo.value) + + +def test_unknown_args_before_double_dash_raises(mocker: MockFixture): + testargs = ["cz", "c", "-this_arg_is_not_supported", "--"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(InvalidCommandArgumentError) as excinfo: + cli.main() + assert "Invalid commitizen arguments were found before -- separator" in str( + excinfo.value + )