Extending the command line

  Wynn Netherland • 2017-11-02

I was relatively late to the terminal party. Spending the first half of my career in a Windows world, I viewed the command line as an artifact of its DOS legacy, not an environment to do anything productive.

Two things happened in the mid 2000s to change that. Apple switched to Intel and bundled a Unix kernel with OS X. Around the same time, Ruby on Rails captured the imagination of a growing number of developers like me who were looking for a better way to build web applications.

Rails has always embraced the command line. From the very first screencast it was apparent that you interacted with Rails through scripts and text files, not wizards and property panes.

Not long after I began riding the Rails there was a moment when the terminal was no longer a command line interface but my command line interface.

Aliases

Like many command line newcomers, I began creating aliases to save some keystrokes and make oft-used commands easier to remember.

# in .bashrc
alias cls="clear"
alias count="wc -l"
alias gpom="git push origin master"

For many developers, Git is the application that brings them to a terminal interface, and it, too, supports aliases. You can create Git aliases programmatically via git config:

git config --global alias.co checkout

This will write the following entry to your global .gitconfig file:

[alias]
  co = checkout

With that in place, you can now run git co instead of git checkout, saving a few keystrokes.

Here are a few handy aliases I've borrowed, stolen, or worked out over a few years of using Git:

# show all branches, even across remotes
branches = branch -a --color -v

# Get short SHA-1 for object
hash = rev-parse --short HEAD

# Show the log with stats, but without merges
lc = log ORIG_HEAD.. --stat --no-merges

# See a tree graph of your git history
lola = log --graph --decorate --pretty=oneline --abbrev-commit --all
# List files known to Git
ls = ls-files

# Show the branches I've been working on and when they were created
mybranches = "!f() { if test $# = 0 ; then set -- refs/remotes ; fi ; git for-each-ref --format='%(authordate:relative)\t%(refname:short)\t%(authoremail)' --sort=authordate \"$@\" | sed -ne \"s/\t<$(git config user.email)>//p\" | column -s '\t' -t ; } ; f"

# update all submodules in a project
subs = submodule foreach git pull origin master

# Pull all branches from the remote that can be merged with fast-forward
up = "!git remote update -p; git merge --ff-only @{u}"

# Open all modified files in my editor
wip = "!$EDITOR $(git ls-files -m)"

Some of the above implementations resemble mini-applications. Thankfully, for more complex tasks Git provides a more robust way to write command line extensions.

Extending Git beyond simple aliases

Git will look for any executable prefixed with git- in your PATH and extend its command line interface. Here's a simple but practical example, an executable named git-conflicts in my bin folder.

#!/bin/sh
# Usage: git-conflicts
# Show list of files in a conflict state.
git ls-files -u | awk '{print $4}' | sort -u

Now when I'm in the middle of a merge or rebase and I encounter conflicts I can run git conflicts to list only the files with conflicts. Building on top of that, I have another script named git-edit-conflicts that will open those files in $EDITOR, which for me is Vim:

#!/bin/sh
# Usage: git-edit-conflicts
# Edit files in a conflict state.
$EDITOR $(git-conflicts)

Another frequently used git-* script of mine is git-ci for checking the build status of my current branch via the GitHub API, regardless of the continuous integration provider:

With that output, I can CMD+Click the URL in iTerm or run git ci -o to open the link to the build on the CI provider's web site automatically.

Perhaps the custom Git command I use the most is git-pr. This script opens the pull request for the current branch on GitHub or begins the process of creating one. So handy.

Wrapping a CLI

I've used git <subcommand> in the examples above, but the truth is I rarely type git when using the Git CLI. I wrap Git with a custom g function:

function g {
  if [[ $# > 0 ]]; then
    git "$@"
  else
    echo "Last commit: $(time_since_last_commit) ago"
    git status --short --branch
  fi
}

In addition to saving a couple of keystrokes on each invocation, it provides quicker access to what I want to see most frequently — the current status of my changes. When called without arguments, it shows the time since last commit and a short status output:

When called with arguments, it forwards those to git. Well, I suppose that's a half truth since I alias git to hub, the excellent command line utility for GitHub that adds many Git command line extensions on its own. It's :turtle::turtle::turtle: all the way down.

Lately I've been getting deeper into Docker and using docker-compose to manage containers in our development environment. Given the frequency I use it, this utility was a prime candidate for a shorter dc alias:

alias dc="docker-compose"

As time went by, I noticed I was performing some repetitive tasks with docker-compose and docker itself that I didn't want to keep typing out or looking up in my shell history. I could have created one-off aliases for each of them, but instead I decided to turn dc into a wrapper:

{% raw %}

#!/usr/bin/env bash

# Borrowed from @ggerrietts

function dc_rebuild {
    docker-compose build "$@" && docker-compose stop "$@" && docker-compose up -d "$@"
}

function dc_cycle {
    docker-compose stop "$@" && docker-compose up -d "$@"
}

function dc_clean {
    docker rm $(docker ps -a -q)
    docker rmi $(docker images -q -f dangling=true)
}

function dc_nuke {
    docker kill $(docker ps -q)
    docker rm $(docker ps -a -q)
    docker rmi $(docker images -q)
}

function dc_console {
    docker-compose exec "$1" /bin/bash
}

function dc_ports {
    docker ps --filter name="$1" --format "{{.Names}}: {{.Ports}}"
}

if [[ $# > 0 ]]; then
  case "$1" in
    clean )
      dc_clean
      ;;
    console )
      shift
      dc_console "$@"
      ;;
    cycle )
      shift
      dc_cycle "$@"
      ;;
    nuke )
      dc_nuke
      ;;
    ports )
      shift
      dc_ports "$@"
      ;;
    rebuild )
      shift
      dc_rebuild "$@"
      ;;
    *)
      docker-compose "$@"
      ;;
  esac
else
  docker-compose
fi

{% endraw %}

With a simple case statement, I can look for custom subcommands (clean, console, etc.) and call custom functions to execute them. If the subcommand doesn't match anything in the case statement, it's forwarded on to docker-compose along with the rest of the arguments.

It's about organization

I think of these approaches as namespaces for my aliases. Instead of a flat list of aliases with esoteric abbreviations, consider leaning into custom git aliases or adding your own subcommands with git-* executables. Instead of several dc-* aliases for shell one-liners, consider building your own wrapper with subcommands.

Wynn Netherland
Wynn Netherland

Engineering Director at Adobe Creative Cloud, team builder, DFW GraphQL meetup organizer, platform nerd, author, and Jesus follower.