DEV Community


Posted on


Writing a custom Git command in Python (eventually)

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 Fine, but then Git thought the command name was, which sucks. The solution to that was a simple ln -s ~/.git-commands/ ~/.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

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")
    branch_name = argv[1]
    run(["git", "switch", "develop"])
    run(["git", "pull", "origin", "develop"])
    run(["git", "switch", "-c", next_branch(branch_name)])

if __name__ == "__main__":
Enter fullscreen mode Exit fullscreen mode

(N.B.: 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, 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!

Top comments (0)