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/gifn_apply/git.py b/tools/git-if-needed/python/gifn_apply/git.py
new file mode 100644
index 0000000..038d167
--- /dev/null
+++ b/tools/git-if-needed/python/gifn_apply/git.py
@@ -0,0 +1,94 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Clone a Git repository."""
+
+from __future__ import annotations
+
+import subprocess
+
+from typing import TYPE_CHECKING
+
+from . import defs
+
+if TYPE_CHECKING:
+    import pathlib
+
+    from typing import Final
+
+
+class GitError(defs.GApplyError):
+    """An error that occurred during a Git-related operation."""
+
+
+def repo_clone(cfg: defs.Config, repo: defs.Repo, tempd: pathlib.Path) -> pathlib.Path:
+    """Clone a Git repository."""
+    loc: Final = cfg.repo_urls.get(repo.origin.upper())
+    if loc is None:
+        raise GitError(f"Unknown repository origin {repo.origin!r}")
+    cfg.log.info(
+        "Cloning the %(origin)s %(repo)s repository from %(loc)s",
+        {"origin": repo.origin, "repo": repo.repo, "loc": loc.url.geturl()},
+    )
+
+    repo_url: Final = loc.url._replace(
+        path=loc.url.path + ("" if loc.url.path.endswith("/") else "/") + repo.repo
+    )
+    repo_dir: Final = tempd / repo.origin / repo.repo
+    if repo_dir.exists() or repo_dir.is_symlink():
+        raise GitError(f"Did not expect {repo_dir} to exist")
+    repo_dir.parent.mkdir(mode=0o755, exist_ok=True, parents=True)
+
+    cfg.log.debug(
+        "About to clone %(repo_url)s into %(repo_dir)s",
+        {"repo_url": repo_url.geturl(), "repo_dir": repo_dir},
+    )
+    try:
+        subprocess.run(
+            ["git", "clone", repo_url.geturl(), repo.repo, "-b", "master"],
+            check=True,
+            cwd=repo_dir.parent,
+        )
+    except (OSError, subprocess.CalledProcessError) as err:
+        raise GitError(
+            f"Could not run `git clone {repo_url.geturl()} {repo.repo}` in {tempd}: {err}"
+        ) from err
+    if not repo_dir.is_dir():
+        raise GitError(f"`git clone` did not create {repo_dir}")
+
+    return repo_dir
+
+
+def list_change_ids(cfg: defs.Config, repo_dir: pathlib.Path) -> list[str]:
+    """Get the Change-Id fields of all the commits reachable from the current head."""
+    cfg.log.info("Getting the change IDs in %(repo_dir)s", {"repo_dir": repo_dir})
+    try:
+        lines = [
+            line
+            for line in subprocess.check_output(
+                ["git", "log", "--pretty=%(trailers:key=Change-Id)", "--reverse"],
+                cwd=repo_dir,
+                encoding="UTF-8",
+                shell=False,
+            ).splitlines()
+            if line
+        ]
+    except (OSError, subprocess.CalledProcessError) as err:
+        raise GitError(f"Could not run `git log` for change IDs in {repo_dir}: {err}") from err
+    except ValueError as err:
+        raise GitError(
+            f"Could not decode the output of `git log` in {repo_dir} into UTF-8 change IDs: {err}"
+        ) from err
+
+    def parse_line(line: str) -> str:
+        """Parse a "Change-Id: Ixxx" line."""
+        fields: Final = line.split()
+        # The magic value will go away once we can use structural pattern matching
+        if (
+            len(fields) != 2  # noqa: PLR2004  # pylint: disable=magic-value-comparison
+            or fields[0] != "Change-Id:"  # pylint: disable=magic-value-comparison
+            or not fields[1].startswith("I")
+        ):
+            raise GitError(f"Unexpected `git log` ouput for change IDs: {line!r}")
+        return fields[1]
+
+    return [parse_line(line) for line in lines]