From ee91310e40bc90a12c2b2a3176a4291a171d3371 Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 20:01:35 +0800 Subject: [PATCH 1/2] fix(git): tolerate noisy `git rev-parse` output in is_git_project The previous `is_git_project` implementation compared `git rev-parse --is-inside-work-tree` output to the literal string `"true"` after stripping. Shell wrappers that prepend ANSI colour codes or extra whitespace (reported on git-bash + `uv tool install` on Windows, #1497) flipped the check to `False` even when git itself considered the directory a valid work tree. Trust the exit code as the primary signal: if `git rev-parse` exits non-zero, we're not in a git context. Then use a loose `endswith("true")` match to distinguish a work tree from a bare-repo interior. Add `logger.debug` lines so users running with `--debug` can see exactly what git returned when the check fails. Closes #1497 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/git.py | 37 ++++++++++++++++++++++++++++++++++++- tests/test_git.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) 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..65695ff1e 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -309,6 +309,49 @@ 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): + """``git rev-parse`` exits 0 but says ``false`` when run from inside the + ``.git`` directory of a bare repo. ``is_git_project`` is meant to gate + work-tree commands, so this case should still 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 +): + """The ``true`` token must be matched as a whole word at the end of the + output, not just any string ending in ``true``. Even though git itself + will never produce these strings, a defensive matcher shouldn't accept + them either.""" + 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 From b03ce747e4da68b285b917eef7e9496106530d30 Mon Sep 17 00:00:00 2001 From: Tim Hsiung <26526132+bearomorphism@users.noreply.github.com> Date: Sat, 9 May 2026 22:26:13 +0800 Subject: [PATCH 2/2] docs(test_git): correct is_git_project test docstrings * the 'not inside work tree' case covers both .git/ subdirs of normal repos AND the root of bare repos (which have no .git directory at all) * the substring-rejection test verifies strict full-string equality after ANSI/whitespace strip, not whole-word matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_git.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_git.py b/tests/test_git.py index 65695ff1e..97b662c2c 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -331,9 +331,9 @@ def test_is_git_project_accepts_loose_true_output(mocker: MockFixture): def test_is_git_project_returns_false_for_bare_repo(mocker: MockFixture): - """``git rev-parse`` exits 0 but says ``false`` when run from inside the - ``.git`` directory of a bare repo. ``is_git_project`` is meant to gate - work-tree commands, so this case should still return ``False``.""" + """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 @@ -343,10 +343,9 @@ def test_is_git_project_returns_false_for_bare_repo(mocker: MockFixture): def test_is_git_project_rejects_strings_containing_true_substring( mocker: MockFixture, noise: str ): - """The ``true`` token must be matched as a whole word at the end of the - output, not just any string ending in ``true``. Even though git itself - will never produce these strings, a defensive matcher shouldn't accept - them either.""" + """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