diff --git a/craft_parts/sources/git_source.py b/craft_parts/sources/git_source.py index 4d3a06316..c31910d55 100644 --- a/craft_parts/sources/git_source.py +++ b/craft_parts/sources/git_source.py @@ -21,12 +21,14 @@ import subprocess import sys from pathlib import Path -from typing import Any, ClassVar, Literal, cast +from typing import Any, Literal, cast import pydantic from overrides import overrides from typing_extensions import Self +from craft_parts.utils.git import get_git_command + from . import errors from .base import ( BaseSourceModel, @@ -81,13 +83,14 @@ class GitSource(SourceHandler): """ source_model = GitSourceModel - command: ClassVar[str] = "git" @classmethod def version(cls) -> str: """Get git version information.""" return subprocess.check_output( - ["git", "version"], universal_newlines=True, stderr=subprocess.DEVNULL + [get_git_command(), "version"], + universal_newlines=True, + stderr=subprocess.DEVNULL, ).strip() @classmethod @@ -118,7 +121,7 @@ def generate_version(cls, *, part_src_dir: Path | None = None) -> str: try: output = ( subprocess.check_output( - ["git", "-C", str(part_src_dir), "describe", "--dirty"], + [get_git_command(), "-C", str(part_src_dir), "describe", "--dirty"], stderr=subprocess.DEVNULL, ) .decode(encoding) @@ -128,7 +131,14 @@ def generate_version(cls, *, part_src_dir: Path | None = None) -> str: # If we fall into this exception it is because the repo is not # tagged at all. proc = subprocess.Popen( # pylint: disable=consider-using-with - ["git", "-C", str(part_src_dir), "describe", "--dirty", "--always"], + [ + get_git_command(), + "-C", + str(part_src_dir), + "describe", + "--dirty", + "--always", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -166,7 +176,7 @@ def __init__( # pylint: disable=too-many-arguments def _fetch_origin_commit(self) -> None: """Fetch from origin, using source-commit if defined.""" - command = [self.command, "-C", str(self.part_src_dir), "fetch", "origin"] + command = [get_git_command(), "-C", str(self.part_src_dir), "fetch", "origin"] if self.source_commit: command.append(self.source_commit) @@ -175,7 +185,7 @@ def _fetch_origin_commit(self) -> None: def _get_current_branch(self) -> str: """Get current git branch.""" command = [ - self.command, + get_git_command(), "-C", str(self.part_src_dir), "branch", @@ -206,7 +216,7 @@ def _pull_existing(self) -> None: else: refspec = "refs/remotes/origin/" + self._get_current_branch() - command_prefix = [self.command, "-C", str(self.part_src_dir)] + command_prefix = [get_git_command(), "-C", str(self.part_src_dir)] command = [*command_prefix, "fetch", "--prune"] if self.source_submodules is None or len(self.source_submodules) > 0: @@ -226,7 +236,7 @@ def _pull_existing(self) -> None: def _clone_new(self) -> None: """Clone a git repository, using submodules, branch, and depth if defined.""" - command = [self.command, "clone"] + command = [get_git_command(), "clone"] if self.source_submodules is None: command.append("--recursive") else: @@ -249,7 +259,7 @@ def _clone_new(self) -> None: if self.source_commit: self._fetch_origin_commit() command = [ - self.command, + get_git_command(), "-C", str(self.part_src_dir), "checkout", @@ -296,7 +306,7 @@ def _get_source_details(self) -> dict[str, str | None]: if not tag and not branch and not commit: commit = self._run_output( - ["git", "-C", str(self.part_src_dir), "rev-parse", "HEAD"] + [get_git_command(), "-C", str(self.part_src_dir), "rev-parse", "HEAD"] ) return { diff --git a/craft_parts/utils/git.py b/craft_parts/utils/git.py new file mode 100644 index 000000000..9a80b0e64 --- /dev/null +++ b/craft_parts/utils/git.py @@ -0,0 +1,32 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Helpers to use git.""" +import shutil +from functools import cache + + +@cache +def get_git_command() -> str: + """Get the git command to use. + + This function will prefer the "craft.git" binary if available on the system, + returning its full path. Otherwise, it will always return "git", without + checking for availability. + """ + if craft_git := shutil.which("craft.git"): + return craft_git + return "git" diff --git a/docs/changelog.rst b/docs/changelog.rst index 36f5f84bd..0861277fa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,8 @@ X.Y.Z (2024-MM-DD) - Set JAVA_HOME environment variable in Java-based plugins. The plugin will try to detect the latest available JDK. - Add an API for :ref:`registering custom source types `. +- Prefer "craft.git" as the binary to handle git sources, in environments where + it is available. 2.1.4 (2024-12-04) ------------------ diff --git a/tests/unit/utils/test_git.py b/tests/unit/utils/test_git.py new file mode 100644 index 000000000..0d4813895 --- /dev/null +++ b/tests/unit/utils/test_git.py @@ -0,0 +1,38 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +import shutil + +import pytest +from craft_parts.utils.git import get_git_command + + +@pytest.mark.parametrize( + ("which_result", "expected_command"), + [ + (None, "git"), + ("/usr/bin/craft.git", "/usr/bin/craft.git"), + ( + "/snap/snapcraft/current/libexec/snapcraft/craft.git", + "/snap/snapcraft/current/libexec/snapcraft/craft.git", + ), + ], +) +def test_get_git_command(which_result, expected_command, mocker): + mocker.patch.object(shutil, "which", return_value=which_result) + get_git_command.cache_clear() + + git_command = get_git_command() + assert git_command == expected_command