From 79bc61d2833808c8ed0872900286ff1d1148fd5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= <hugo@lysator.liu.se>
Date: Sun, 10 Sep 2023 19:56:18 +0200
Subject: [PATCH] Add git-children.

---
 Makefile        |   1 +
 git-children.py | 137 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 138 insertions(+)
 create mode 100755 git-children.py

diff --git a/Makefile b/Makefile
index 0e413ee..523a612 100644
--- a/Makefile
+++ b/Makefile
@@ -4,5 +4,6 @@ PREFIX := /usr
 DESTDIR =
 
 install:
+	install git-children.py $(DESTDIR)$(PREFIX)/bin/git-children
 	install git-ls.scm $(DESTDIR)$(PREFIX)/bin/git-ls
 	install git-open.py $(DESTDIR)$(PREFIX)/bin/git-open
diff --git a/git-children.py b/git-children.py
new file mode 100755
index 0000000..133f38a
--- /dev/null
+++ b/git-children.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+
+"""
+Script for easily referencing children of a specific git commit.
+
+Many of these procedures refer to the "current repo". This is simply
+the closest containing repo from the current working directory (per
+the git command lines rules).
+
+The two types of move currently supported are:
+
+- ``^[<n>]`` Move to the *nth* (default first) child of the node.
+- ``~[<n>]`` Move *n* commits backwards, always chocing the first
+             child.
+"""
+
+import subprocess
+import re
+from typing import Literal, cast, Iterator, TypeAlias
+import argparse
+
+
+ChangeType: TypeAlias = Literal['^', '~']
+Change: TypeAlias = tuple[ChangeType, int]
+
+
+def children() -> dict[bytes, list[bytes]]:
+    """Load list of all relations in the current git repo."""
+    cmd = subprocess.Popen("git rev-list --children --all".split(' '),
+                           stdout=subprocess.PIPE)
+
+    tree: dict[bytes, list[bytes]] = {}
+
+    for line in cast(Iterator[bytes], cmd.stdout):
+        commit, *children = line.rstrip().split(b' ')
+        tree[commit] = children
+
+    return tree
+
+
+def revparse(name: str) -> bytes:
+    """Return the revparse of the name, in the current repo."""
+    return subprocess.run(["git", "rev-parse", name],
+                          capture_output=True).stdout.rstrip()
+
+
+def parse_refspec(spec: str) -> tuple[str, list[Change]]:
+    """
+    Parse a git refspec.
+
+    Currently only basic syntax is supported, and is on the form
+
+        BASE^^
+
+    Where BASE is dircet reference to a commit (through a tag, branch,
+    or direct id), and `^^` is how we should move from that commit.
+    The exact same rules as for git-rev-parse(1) applies. (We just
+    interpret them in reverse).
+
+    https://git-scm.com/docs/revisions/
+
+    :return:
+        A tuple containing the base name, and a list containing each
+        change. For changes where no value was given, 1 is used.
+    """
+    ms = list(re.finditer(r'([0-9]*)([~^])', ''.join(reversed(spec))))
+
+    changes: list[Change] = []
+    for m in ms:
+        changes.append((cast(ChangeType, m.group(2)),
+                        int(m.group(1)) if m.group(1) else 1))
+
+    base: str
+    if ms:
+        base = ''.join(reversed(''.join(reversed(spec))[ms[-1].end():]))
+    else:
+        base = spec
+
+    return base, changes
+
+
+def resolve_change_mods(
+        tree: dict[bytes, list[bytes]],
+        base: str,
+        changes: list[Change]) -> bytes:
+    """
+    Resolve a refspec finding children.
+
+    This takes the output of ``parse_refspec``, and locates the wanted
+    commit.
+
+    :param tree:
+        Tree where the keys are commit ids, and the children that
+        commits childrens id.
+    :param base:
+        The commit to start the search from. Can be either a branch,
+        tag, id, or anything else git-rev-parse(1) handles.
+    :param change:
+        How we should move from that commit.
+    :returns:
+        The found commits id.
+    """
+    node: bytes = revparse(base)
+
+    for (opt, count) in changes:
+        match opt:
+            case '^':
+                node = tree[node][count - 1]
+            case '~':
+                for _ in range(count):
+                    node = tree[node][0]
+
+    return node
+
+
+def build_parser():
+    """Construct the command line argument parser."""
+    parser = argparse.ArgumentParser(
+            prog="git children",
+            description="Easily locale decendants of a git commit.")
+    parser.add_argument('revision',
+                        action='store',
+                        help="Target revision. Example: 'HEAD^^'.")
+    return parser
+
+
+def main():
+    """Entry point of program."""
+    args = build_parser().parse_args()
+
+    base, mods = parse_refspec(args.revision)
+    ref = resolve_change_mods(children(), base, mods)
+    print(ref.decode('ASCII'))
+
+
+if __name__ == '__main__':
+    main()
-- 
GitLab