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