diff --git a/Makefile b/Makefile index 0e413ee52094caf91150f2c0d2b08747c0236ec8..523a61287259aeb5f5b0dc40c045ac742ae5482b 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 0000000000000000000000000000000000000000..133f38a3c6cfc2657b9acf6e90f13fb87cf29499 --- /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()