# utils.py -- Git compatibility utilities
# Copyright (C) 2010 Google, Inc.
#
# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
# General Public License as public by the Free Software Foundation; version 2.0
# or (at your option) any later version. You can redistribute it and/or
# modify it under the terms of either of these two licenses.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# You should have received a copy of the licenses; if not, see
# for a copy of the GNU General Public License
# and for a copy of the Apache
# License, Version 2.0.
#
"""Utilities for interacting with cgit."""
import errno
import functools
import os
import shutil
import socket
import stat
import subprocess
import sys
import tempfile
import time
from dulwich.protocol import TCP_GIT_PORT
from dulwich.repo import Repo
from .. import SkipTest, TestCase
_DEFAULT_GIT = "git"
_VERSION_LEN = 4
_REPOS_DATA_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "testdata", "repos")
)
def git_version(git_path=_DEFAULT_GIT):
"""Attempt to determine the version of git currently installed.
Args:
git_path: Path to the git executable; defaults to the version in
the system path.
Returns: A tuple of ints of the form (major, minor, point, sub-point), or
None if no git installation was found.
"""
try:
output = run_git_or_fail(["--version"], git_path=git_path)
except OSError:
return None
version_prefix = b"git version "
if not output.startswith(version_prefix):
return None
parts = output[len(version_prefix) :].split(b".")
nums = []
for part in parts:
try:
nums.append(int(part))
except ValueError:
break
while len(nums) < _VERSION_LEN:
nums.append(0)
return tuple(nums[:_VERSION_LEN])
def require_git_version(required_version, git_path=_DEFAULT_GIT) -> None:
"""Require git version >= version, or skip the calling test.
Args:
required_version: A tuple of ints of the form (major, minor, point,
sub-point); omitted components default to 0.
git_path: Path to the git executable; defaults to the version in
the system path.
Raises:
ValueError: if the required version tuple has too many parts.
SkipTest: if no suitable git version was found at the given path.
"""
found_version = git_version(git_path=git_path)
if found_version is None:
raise SkipTest(f"Test requires git >= {required_version}, but c git not found")
if len(required_version) > _VERSION_LEN:
raise ValueError(
"Invalid version tuple %s, expected %i parts"
% (required_version, _VERSION_LEN)
)
required_version = list(required_version)
while len(found_version) < len(required_version):
required_version.append(0)
required_version = tuple(required_version)
if found_version < required_version:
required_version = ".".join(map(str, required_version))
found_version = ".".join(map(str, found_version))
raise SkipTest(
f"Test requires git >= {required_version}, found {found_version}"
)
def run_git(
args,
git_path=_DEFAULT_GIT,
input=None,
capture_stdout=False,
capture_stderr=False,
**popen_kwargs,
):
"""Run a git command.
Input is piped from the input parameter and output is sent to the standard
streams, unless capture_stdout is set.
Args:
args: A list of args to the git command.
git_path: Path to to the git executable.
input: Input data to be sent to stdin.
capture_stdout: Whether to capture and return stdout.
popen_kwargs: Additional kwargs for subprocess.Popen;
stdin/stdout args are ignored.
Returns: A tuple of (returncode, stdout contents, stderr contents).
If capture_stdout is False, None will be returned as stdout contents.
If capture_stderr is False, None will be returned as stderr contents.
Raises:
OSError: if the git executable was not found.
"""
env = popen_kwargs.pop("env", {})
env["LC_ALL"] = env["LANG"] = "C"
env["PATH"] = os.getenv("PATH")
args = [git_path, *args]
popen_kwargs["stdin"] = subprocess.PIPE
if capture_stdout:
popen_kwargs["stdout"] = subprocess.PIPE
else:
popen_kwargs.pop("stdout", None)
if capture_stderr:
popen_kwargs["stderr"] = subprocess.PIPE
else:
popen_kwargs.pop("stderr", None)
p = subprocess.Popen(args, env=env, **popen_kwargs)
stdout, stderr = p.communicate(input=input)
return (p.returncode, stdout, stderr)
def run_git_or_fail(args, git_path=_DEFAULT_GIT, input=None, **popen_kwargs):
"""Run a git command, capture stdout/stderr, and fail if git fails."""
if "stderr" not in popen_kwargs:
popen_kwargs["stderr"] = subprocess.STDOUT
returncode, stdout, stderr = run_git(
args,
git_path=git_path,
input=input,
capture_stdout=True,
capture_stderr=True,
**popen_kwargs,
)
if returncode != 0:
raise AssertionError(
"git with args %r failed with %d: stdout=%r stderr=%r"
% (args, returncode, stdout, stderr)
)
return stdout
def import_repo_to_dir(name):
"""Import a repo from a fast-export file in a temporary directory.
These are used rather than binary repos for compat tests because they are
more compact and human-editable, and we already depend on git.
Args:
name: The name of the repository export file, relative to
dulwich/tests/data/repos.
Returns: The path to the imported repository.
"""
temp_dir = tempfile.mkdtemp()
export_path = os.path.join(_REPOS_DATA_DIR, name)
temp_repo_dir = os.path.join(temp_dir, name)
export_file = open(export_path, "rb")
run_git_or_fail(["init", "--quiet", "--bare", temp_repo_dir])
run_git_or_fail(["fast-import"], input=export_file.read(), cwd=temp_repo_dir)
export_file.close()
return temp_repo_dir
def check_for_daemon(limit=10, delay=0.1, timeout=0.1, port=TCP_GIT_PORT) -> bool:
"""Check for a running TCP daemon.
Defaults to checking 10 times with a delay of 0.1 sec between tries.
Args:
limit: Number of attempts before deciding no daemon is running.
delay: Delay between connection attempts.
timeout: Socket timeout for connection attempts.
port: Port on which we expect the daemon to appear.
Returns: A boolean, true if a daemon is running on the specified port,
false if not.
"""
for _ in range(limit):
time.sleep(delay)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(delay)
try:
s.connect(("localhost", port))
return True
except socket.timeout:
pass
except OSError as e:
if getattr(e, "errno", False) and e.errno != errno.ECONNREFUSED:
raise
elif e.args[0] != errno.ECONNREFUSED:
raise
finally:
s.close()
return False
class CompatTestCase(TestCase):
"""Test case that requires git for compatibility checks.
Subclasses can change the git version required by overriding
min_git_version.
"""
min_git_version: tuple[int, ...] = (1, 5, 0)
def setUp(self) -> None:
super().setUp()
require_git_version(self.min_git_version)
def assertObjectStoreEqual(self, store1, store2) -> None:
self.assertEqual(sorted(set(store1)), sorted(set(store2)))
def assertReposEqual(self, repo1, repo2) -> None:
self.assertEqual(repo1.get_refs(), repo2.get_refs())
self.assertObjectStoreEqual(repo1.object_store, repo2.object_store)
def assertReposNotEqual(self, repo1, repo2) -> None:
refs1 = repo1.get_refs()
objs1 = set(repo1.object_store)
refs2 = repo2.get_refs()
objs2 = set(repo2.object_store)
self.assertFalse(refs1 == refs2 and objs1 == objs2)
def import_repo(self, name):
"""Import a repo from a fast-export file in a temporary directory.
Args:
name: The name of the repository export file, relative to
dulwich/tests/data/repos.
Returns: An initialized Repo object that lives in a temporary
directory.
"""
path = import_repo_to_dir(name)
repo = Repo(path)
def cleanup() -> None:
repo.close()
rmtree_ro(os.path.dirname(path.rstrip(os.sep)))
self.addCleanup(cleanup)
return repo
if sys.platform == "win32":
def remove_ro(action, name, exc) -> None:
os.chmod(name, stat.S_IWRITE)
os.remove(name)
rmtree_ro = functools.partial(shutil.rmtree, onerror=remove_ro)
else:
rmtree_ro = shutil.rmtree