DEV Community

loading...

Writing a custom Git command in Python (eventually)

sam
i don't like webdev.
・4 min read

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:

#! /usr/bin/sh

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

new_branch "$@"
Enter fullscreen mode Exit fullscreen mode

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—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

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—i.e. going from b to c.

Python to the rescue:

#! /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()
Enter fullscreen mode Exit fullscreen mode

(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 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à!

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!

Discussion (0)