Writing a custom Git command in Python (eventually)
Tags: git, python
Published: 2021-03-26 17:31:14

---
title: Writing a custom Git command in Python (eventually)
published: true
description: My first custom Git command, and how I got there.
tags: Git, Python
---

I have a pretty standardized workflow at my day job. I log on, check Jira, and start banging away. I fork a new branch off of our development branch (conveniently named `develop`) for every new ticket I'm assigned. I like doing things this way, because it keeps the history clean and makes merges and code review easier. (If you've ever had to pull out a feature that's been merged in piecemeal, I'm sure you'll agree.)

This may strike you as a fairly rote series of steps; it certainly struck me as such, so I decided to try writing a custom Git command to help automate it. You can add a custom command to Git by creating an executable file on your `$PATH` that's named `git-$COMMAND_NAME`. Git will strip off the `git-` prefix when you invoke it, e.g. `git-my-command` will be runnable as `git my-command`.

My first attempt was a simple Bash script:

```bash
#! /usr/bin/sh

new_branch() {
  git switch develop
  git pull origin develop
  git switch -c "$1"
}

new_branch "$@"
```

I made a `.git-commands` folder in my home directory and saved this as `git-new-branch`. I made the file executable and added `~/.git-commands` to my `$PATH`.

It worked. Pretty well.

There were two pain points. First, my editor (Neovim + CoC) didn't pick up on the fact that it was a Bash script, so I didn't get any linting/syntax highlighting/debugging help. I have `coc-sh` and `shellcheck` set up, but they were silent. (It is here that I must admit that what I shared above is not the original version, which didn't actually work.) The solution to this issue was to rename the file to `git-new-branch.sh`. Fine, but then Git thought the command name was `new-branch.sh`, which sucks. The solution to _that_ was a simple `ln -s ~/.git-commands/git-new-branch.sh ~/.git-commands/git-new-branch`. Easy enough.

The second problem was trickier. You see, I'm not a very good developer. As such, tickets I'm assigned are frequently re-opened in the course of the QA process. (A hearty shout-out to QA people the world over, and especially at my company, for their tireless diligence and infinite patience.) It's my practice in such situations to fork a new branch off of `develop` with the same ticket name plus a letter. So, should ticket `1234` be re-opened (after the original was merged into `develop` and deployed to our development server for QA), I'd open `1234-b`, `1234-c`, etc. My new command didn't handle this. The issue wasn't disastrous&mdash;I'd just get a fresh pull of `develop` and then a chiding from Git about the pre-existing branch name. I _could_ probably just remember that it was a reopened ticket and add the letters myself, but again, tickets are sometimes re-opened multiple times, and the whole point of this exercise is making my life easier.

![Black and white GIF of a man trying to shove lettuce into a food processor with a mallet](https://hg1.funnyjunk.com/gifs/There_c33a25_5423043.gif)

There's got to be a better way!

I tried fiddling around with `sed` and managed to isolate the suffix, but then ran into some limitations, both around Bash's character-handling deficiencies and (more saliently) my own Bash-handling deficiencies, specifically around how to "increment" a character&mdash;i.e. going from `b` to `c`.

Python to the rescue:

```python
#! /usr/bin/env python3

import re
from subprocess import run
from sys import argv, exit


def branches_list():
    return run(
        ["git", "--no-pager", "branch", "--list"], capture_output=True, text=True
    ).stdout


def next_branch(branch_name):
    p = f"{branch_name}(?:-(\\w))?"
    matches = re.findall(p, branches_list())
    if len(matches) == 0:
        return branch_name
    if len(matches) == 1 and matches[0] == "":
        return f"{branch_name}-b"
    return f"{branch_name}-{chr(ord(matches[-1]) + 1)}"


def main():
    if len(argv) != 2:
        print("Please supply a branch name")
        exit(1)
    branch_name = argv[1]
    run(["git", "switch", "develop"])
    run(["git", "pull", "origin", "develop"])
    run(["git", "switch", "-c", next_branch(branch_name)])
    exit(0)


if __name__ == "__main__":
    main()
```

(N.B.: `subprocess.run` was introduced in 3.5; if you're using an older version you'll need to fall back to `subprocess.check_output`; see [the docs](https://docs.python.org/3/library/subprocess.html#older-high-level-api) for more.)

I threw this into `new_branch.py`, marked it as executable, deleted the symbolic link to the Bash version and created a new one to this & voil&agrave;!

As much as it can feel (to me, at least) like a crutch sometimes, Python legitimately rules for this kind of thing. It was born as a "glue language," and has all the necessary batteries included. I could have reached for Haskell or Rust (Haskell's `Ord` instance for `Char` especially would've made some of this nicer) but I didn't want to mess with compiling a "production" build and then figuring out where Stack or Cargo (respectively) put it and so on. (Yes, I could've written a Stack script but I don't really know how to do that and didn't want to bother.)

I guess the lessons here are:
* Custom Git commands aren't that hard to write, and we (I) should probably be writing more of them.
* While professional development and personal growth are worthwhile goals, sometimes the easy path is the right one.

(One last unsolved irritant: some not-terribly-thorough Googling failed to turn up a way to add Git commands per-repo, as opposed to globally. If anyone has a solution for this, please let me know.)

If anyone's got any custom Git commands they're particularly proud of, feel free to drop them in the comments!