Add the gifn-apply test tool

Add a tool that checks out the repositories mentioned in
a git-if-needed patch series, runs gif-it-needed, and performs
some checks on its operation. This tool will eventually be used in
a Zuul test job for this repository.

Change-Id: Id02fb7c21f5ab34d9639bf845fcc3961d929b13b
diff --git a/tools/git-if-needed/python/unit_tests/__init__.py b/tools/git-if-needed/python/unit_tests/__init__.py
new file mode 100644
index 0000000..621bbac
--- /dev/null
+++ b/tools/git-if-needed/python/unit_tests/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Unit tests for the gifn-apply tool."""
diff --git a/tools/git-if-needed/python/unit_tests/test_repo_urls.py b/tools/git-if-needed/python/unit_tests/test_repo_urls.py
new file mode 100644
index 0000000..54c6bf9
--- /dev/null
+++ b/tools/git-if-needed/python/unit_tests/test_repo_urls.py
@@ -0,0 +1,50 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Test the parsing of base/URL pairs."""
+
+from __future__ import annotations
+
+from typing import NamedTuple
+
+import pytest
+
+from gifn_apply import repo_url
+
+
+class TrivURL(NamedTuple):
+    """The basic elements of a parsed URL."""
+
+    scheme: str
+    netloc: str
+    path: str
+
+
+PARSE_URLS: list[tuple[str, str, TrivURL | None]] = [
+    ("", "https://github.com/openstack", None),
+    ("ostack", "https://github.com/openstack", None),
+    ("OSTACK!", "https://github.com/openstack", None),
+    ("OSTACK", "https://github.com/openstack", TrivURL("https", "github.com", "/openstack/")),
+    ("OSTACK", "https://github.com/openstack/", TrivURL("https", "github.com", "/openstack/")),
+    ("local", "file:///absolute/path", None),
+    ("LOCAL_3!", "file:///absolute/path", None),
+    ("LOCAL_3", "file://host", None),
+    ("LOCAL_3", "file:relative/path", None),
+    ("LOCAL_3", "file:///absolute/path", TrivURL("file", "", "/absolute/path/")),
+    ("OSTACK", "file:///absolute/path", TrivURL("file", "", "/absolute/path/")),
+    ("OSTACK", "httpx://github.com/openstack", None),
+    ("OSTACK", "loc", None),
+    ("OSTACK", "/path", None),
+    ("OSTACK", ":path", None),
+]
+
+
+@pytest.mark.parametrize(("base", "value", "expected"), PARSE_URLS)
+def test_parse_url(base: str, value: str, expected: TrivURL | None) -> None:
+    """Test the base parse_url() function."""
+    res = repo_url.parse_url(f"R_{base}", base, value)
+    if expected is None:
+        assert isinstance(res, repo_url.RepoURLError)
+    else:
+        assert isinstance(res, repo_url.RepoURLOK)
+        url = res.url.url
+        assert (url.scheme, url.netloc, url.path) == expected
diff --git a/tools/git-if-needed/python/unit_tests/test_util.py b/tools/git-if-needed/python/unit_tests/test_util.py
new file mode 100644
index 0000000..bdcb5ad
--- /dev/null
+++ b/tools/git-if-needed/python/unit_tests/test_util.py
@@ -0,0 +1,21 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Test the gifn_apply.util functions."""
+
+import pytest
+
+from gifn_apply import util
+
+
+@pytest.mark.parametrize(
+    ("value", "prefix", "expected"),
+    [
+        ("hello", "goodbye", "hello"),
+        ("hello", "hel", "lo"),
+        ("hel", "hello", "hel"),
+        ("hello", "hello", ""),
+    ],
+)
+def test_remove_prefix(value: str, prefix: str, expected: str) -> None:
+    """Test our hand-rolled str.removeprefix() implementation."""
+    assert util.str_removeprefix(value, prefix) == expected