diff --git a/commitizen/git.py b/commitizen/git.py index ce9f440c9..3dbb8ee3d 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -1,8 +1,10 @@ from __future__ import annotations import os +import re from enum import Enum from functools import lru_cache +from logging import getLogger from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING @@ -14,6 +16,15 @@ from collections.abc import Sequence +logger = getLogger("commitizen") + + +# Match common ANSI control sequences (CSI ``\x1b[...letter`` and SGR resets) +# so wrappers that wrap git's output in colour codes don't break the +# ``true`` / ``false`` parse in :func:`is_git_project` (#1497). +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + class EOLType(Enum): """The EOL type from `git config core.eol`.""" @@ -312,8 +323,32 @@ def is_staging_clean() -> bool: def is_git_project() -> bool: + """Check whether we're inside a git work tree. + + Trusts ``git rev-parse``'s exit code as the primary signal: if the command + exits non-zero, we're not in a git context. The textual output (``true`` / + ``false``) is then used to distinguish a work tree from a bare-repo + interior. ANSI colour codes (which some shell wrappers inject) are + stripped before the exact-match check, so legitimate-looking strings like + ``untrue`` / ``nottrue`` are still rejected (#1497). + """ c = cmd.run(["git", "rev-parse", "--is-inside-work-tree"]) - return c.out.strip() == "true" + if c.return_code != 0: + logger.debug( + "is_git_project: git rev-parse failed (rc=%d) out=%r err=%r", + c.return_code, + c.out, + c.err, + ) + return False + cleaned = _ANSI_ESCAPE.sub("", c.out).strip().lower() + inside_work_tree = cleaned == "true" + if not inside_work_tree: + logger.debug( + "is_git_project: git rev-parse said not a work tree: out=%r", + c.out, + ) + return inside_work_tree def get_core_editor() -> str | None: diff --git a/tests/test_git.py b/tests/test_git.py index 51586eb22..97b662c2c 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -309,6 +309,48 @@ def test_is_staging_clean_when_updating_file(): assert git.is_staging_clean() is False +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_is_git_project_inside_work_tree(): + assert git.is_git_project() is True + + +def test_is_git_project_outside_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """When ``git rev-parse`` exits non-zero (no repo above us), + ``is_git_project`` returns ``False``.""" + monkeypatch.chdir(tmp_path) + assert git.is_git_project() is False + + +def test_is_git_project_accepts_loose_true_output(mocker: MockFixture): + """Regression test for #1497: shell wrappers that prepend ANSI colour + codes or trailing whitespace to ``git rev-parse``'s output must not flip + ``is_git_project`` to ``False``.""" + fake = cmd.Command("\x1b[0mtrue\r\n", "", b"", b"", 0) + mocker.patch("commitizen.cmd.run", return_value=fake) + assert git.is_git_project() is True + + +def test_is_git_project_returns_false_for_bare_repo(mocker: MockFixture): + """When ``git rev-parse --is-inside-work-tree`` returns ``false`` + (inside ``.git/`` of a normal repo, or at the root of a bare repo), + ``is_git_project`` should return ``False``.""" + fake = cmd.Command("false\n", "", b"", b"", 0) + mocker.patch("commitizen.cmd.run", return_value=fake) + assert git.is_git_project() is False + + +@pytest.mark.parametrize("noise", ["untrue", "nottrue", "test true false"]) +def test_is_git_project_rejects_strings_containing_true_substring( + mocker: MockFixture, noise: str +): + """After stripping ANSI escapes and whitespace, only the exact string + ``true`` is accepted; outputs like ``untrue``, ``nottrue``, or + ``true false`` are rejected.""" + fake = cmd.Command(f"{noise}\n", "", b"", b"", 0) + mocker.patch("commitizen.cmd.run", return_value=fake) + assert git.is_git_project() is False + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_get_eol_for_open(): assert git.EOLType.for_open() == os.linesep