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/quilt.py b/tools/git-if-needed/python/gifn_apply/quilt.py
new file mode 100644
index 0000000..3de9049
--- /dev/null
+++ b/tools/git-if-needed/python/gifn_apply/quilt.py
@@ -0,0 +1,88 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Parse a quilt patches file, then parse the patches themselves."""
+
+from __future__ import annotations
+
+import dataclasses
+import pathlib
+import re
+
+from . import defs
+
+
+_REPO_PARTS = 2
+
+_RE_CHANGE_ID = re.compile(r"^ \s* Change-Id \s* : \s* (?P<value> I [0-9a-f]+ ) \s* $", re.X)
+
+_RE_DIFF_START = re.compile(r"^ --- [ ]", re.X)
+
+
+class QuiltError(defs.GApplyError):
+    """An error that occurred while parsing the quilt patch structure."""
+
+
+@dataclasses.dataclass(frozen=True)
+class Patch:
+    """A single patch read from the quilt series file."""
+
+    change_id: str
+    filename: str
+    path: pathlib.Path
+    relpath: pathlib.Path
+    repo: defs.Repo
+
+
+def _extract_change_id(patchfile: pathlib.Path) -> str:
+    """Extract the value of the patch's Change-Id trailer."""
+    change_id = None
+    for line in patchfile.read_text(encoding="UTF-8").splitlines():
+        if _RE_DIFF_START.match(line):
+            if change_id is None:
+                raise defs.GApplyError(f"No Change-Id line found in {patchfile}")
+
+            return change_id
+
+        change = _RE_CHANGE_ID.match(line)
+        if change is not None:
+            change_id = change.group("value")
+
+    raise defs.GApplyError(f"No diff start line ('--- ...') found in {patchfile}")
+
+
+def parse_series(cfg: defs.Config) -> tuple[list[Patch], list[defs.Repo]]:
+    """Parse a series file, return a list of patches and a list of repository names."""
+    repos = set()
+
+    def parse_line(sline: str) -> Patch:
+        """Parse a single relative patch filename read from the series file."""
+        fields = sline.split()
+        if len(fields) != 1:
+            raise NotImplementedError(f"quilt patch options not supported yet: {sline!r}")
+        filename = fields[0]
+
+        relpath = pathlib.Path(filename)
+        if (
+            relpath.is_absolute()
+            or len(relpath.parts) <= _REPO_PARTS
+            or any(part.startswith(".") for part in relpath.parts)
+        ):
+            raise QuiltError(f"Invalid patch filename {filename!r} in {cfg.series}")
+
+        repo = defs.Repo(origin=relpath.parts[0], repo="/".join(relpath.parts[1:_REPO_PARTS]))
+        repos.add(repo)
+
+        patchfile = cfg.patches / relpath
+        if not patchfile.is_file():
+            raise QuiltError(f"Need a regular patch file at {patchfile}")
+
+        change_id = _extract_change_id(patchfile)
+        return Patch(
+            change_id=change_id, filename=filename, path=patchfile, relpath=relpath, repo=repo
+        )
+
+    if not cfg.series.is_file():
+        raise QuiltError(f"Need a regular series file at {cfg.series}")
+
+    res = [parse_line(line) for line in cfg.series.read_text(encoding="UTF-8").splitlines()]
+    return res, sorted(repos)