Version Control

Custom Git Commands and Aliases

Build custom Git commands and aliases with Node.js scripts, npm packaging, and tab completion for developer productivity.

Custom Git Commands and Aliases

Overview

Every developer types dozens of Git commands per day, and most of them are the same repetitive sequences. Custom Git commands and aliases let you compress multi-step workflows into single invocations, enforce team conventions, and build genuinely useful tooling on top of Git's plumbing. I have been building and shipping custom Git commands for years, and the productivity gains compound faster than you would expect.

Prerequisites

  • Git 2.20 or later installed and configured
  • Node.js 16+ (for the Node.js-based commands)
  • Basic familiarity with Git operations (commit, branch, merge, log)
  • A terminal with Bash, Zsh, or PowerShell
  • npm for packaging and distribution

Why Custom Git Commands Boost Productivity

The average developer runs somewhere between 50 and 200 Git commands per day. If you shave even a few seconds off each one, the math adds up fast. But the real value is not keystroke savings. It is eliminating cognitive overhead.

When I run git cleanup, I do not have to think about which branches are merged, which remote tracking branches are stale, or whether I should prune. The command encapsulates the decision. When I run git standup, I get a formatted summary of what I did yesterday without constructing a log query from scratch.

Custom commands also enforce consistency across a team. Instead of documenting a five-step release process, you ship a git release command that does it correctly every time.

Simple Aliases in .gitconfig

The fastest way to customize Git is through aliases in your .gitconfig file. These live in ~/.gitconfig (global) or .git/config (per-repo).

[alias]
    s = status -sb
    co = checkout
    br = branch -vv
    ci = commit
    amend = commit --amend --no-edit
    unstage = reset HEAD --
    last = log -1 HEAD --format='%C(yellow)%h%Creset %s (%C(green)%ar%Creset)'
    lg = log --oneline --graph --decorate --all -20
    branches = branch -a --sort=-committerdate
    stashes = stash list --format='%C(yellow)%gd%Creset: %C(green)%cr%Creset %gs'
    wip = !git add -A && git commit -m 'WIP [skip ci]'
    undo = reset --soft HEAD~1
    discard = checkout --
    merged = branch --merged main
    contributors = shortlog -sne

Test any alias immediately:

$ git s
## master...origin/master
 M src/app.js
?? temp.log

$ git lg
* a3f1c2d (HEAD -> master) Fix authentication middleware
* 8b2e4f1 Add rate limiting to API routes
* 1d5a7c3 (origin/master) Update dependencies
* e9f0b2a Refactor database connection pool

These simple aliases are just string substitutions. Git replaces the alias with the expanded command and runs it.

Complex Aliases with Shell Execution

When you prefix an alias with !, Git runs it as a shell command. This unlocks conditionals, pipes, and multi-command sequences.

[alias]
    # Delete all branches merged into main
    purge = !git branch --merged main | grep -v 'main\\|master\\|\\*' | xargs -r git branch -d

    # Show files changed in the last commit
    changed = !git diff --name-only HEAD~1 HEAD

    # Find commits by message content
    find = !git log --all --oneline --grep

    # Show a diff stat summary for a branch vs main
    review = !git diff --stat main...HEAD

    # Interactive rebase last N commits (pass number as argument)
    rebase-last = "!f() { git rebase -i HEAD~$1; }; f"

    # Create a branch and switch to it
    fresh = "!f() { git checkout -b $1 && git push -u origin $1; }; f"

    # Show commit count per author for current branch
    who = !git shortlog -sne --no-merges

    # Open the repo in the browser (GitHub)
    browse = !git remote get-url origin | sed 's/[email protected]:/https:\\/\\/github.com\\//' | sed 's/.git$//' | xargs open

The "!f() { ... }; f" pattern defines an inline function, which lets you accept arguments. The $1, $2 placeholders map to the arguments you pass after the alias name.

$ git rebase-last 5
# Opens interactive rebase for last 5 commits

$ git fresh feature/user-auth
# Creates branch, switches to it, pushes to origin

Building Custom Git Commands as Executable Scripts

Git has a powerful convention: any executable on your PATH named git-* becomes a Git subcommand. If you create git-cleanup and make it executable, running git cleanup just works. Git finds it, runs it, and even includes it in git help.

This is the foundation for building serious custom tooling.

Basic Bash Script Example

Create a file called git-cleanup:

#!/usr/bin/env bash
# git-cleanup: Remove merged branches and prune remotes

set -euo pipefail

MAIN_BRANCH=${1:-main}

echo "Fetching and pruning remotes..."
git fetch --prune

echo ""
echo "Branches merged into $MAIN_BRANCH:"
MERGED=$(git branch --merged "$MAIN_BRANCH" | grep -v "^\*\|$MAIN_BRANCH\|master" || true)

if [ -z "$MERGED" ]; then
    echo "  No merged branches to clean up."
else
    echo "$MERGED"
    echo ""
    read -p "Delete these branches? (y/N) " -n 1 -r
    echo ""
    if [[ $REPLY =~ ^[Yy]$ ]]; then
        echo "$MERGED" | xargs git branch -d
        echo "Done."
    else
        echo "Aborted."
    fi
fi
$ chmod +x git-cleanup
$ sudo mv git-cleanup /usr/local/bin/

$ git cleanup
Fetching and pruning remotes...

Branches merged into main:
  feature/add-logging
  fix/timeout-bug
  chore/update-deps

Delete these branches? (y/N) y
Deleted branch feature/add-logging (was a3f1c2d).
Deleted branch fix/timeout-bug (was 8b2e4f1).
Deleted branch chore/update-deps (was 1d5a7c3).
Done.

Implementing a git-recent Command

This command shows your most recently checked-out branches with timestamps, which is surprisingly useful when you are juggling multiple features.

#!/usr/bin/env bash
# git-recent: Show recently checked out branches

COUNT=${1:-10}

git reflog show --format='%gs' \
    | grep 'checkout: moving' \
    | sed 's/checkout: moving from .* to //' \
    | awk '!seen[$0]++' \
    | head -n "$COUNT" \
    | while read -r branch; do
        if git rev-parse --verify "$branch" >/dev/null 2>&1; then
            DATE=$(git log -1 --format='%C(green)%ar%Creset' "$branch" 2>/dev/null)
            COMMIT=$(git log -1 --format='%C(yellow)%h%Creset %s' "$branch" 2>/dev/null)
            printf "  %-30s %s  %s\n" "$branch" "$DATE" "$COMMIT"
        fi
    done
$ git recent
  feature/auth-refactor          2 hours ago   a3f1c2d Add JWT refresh token support
  fix/memory-leak                5 hours ago   8b2e4f1 Fix connection pool drain
  main                           1 day ago     1d5a7c3 Merge pull request #142
  feature/dashboard              3 days ago    e9f0b2a Add chart components

Implementing a git-standup Command

This command answers the question every standup meeting asks: "What did you do yesterday?"

#!/usr/bin/env bash
# git-standup: Show commits from the last working day

AUTHOR=${1:-$(git config user.name)}
SINCE=${2:-"yesterday"}

echo "Commits by $AUTHOR since $SINCE:"
echo ""

git log \
    --all \
    --author="$AUTHOR" \
    --since="$SINCE" \
    --format='  %C(yellow)%h%Creset %s %C(green)(%ar)%Creset %C(blue)[%an]%Creset' \
    --no-merges

COMMIT_COUNT=$(git log --all --author="$AUTHOR" --since="$SINCE" --no-merges --oneline | wc -l | tr -d ' ')
echo ""
echo "$COMMIT_COUNT commits found."
$ git standup
Commits by Shane Larson since yesterday:

  a3f1c2d Fix rate limiter configuration (3 hours ago) [Shane Larson]
  8b2e4f1 Add integration tests for auth flow (5 hours ago) [Shane Larson]
  1d5a7c3 Refactor middleware chain (8 hours ago) [Shane Larson]

3 commits found.

$ git standup "Jane Smith" "3 days ago"
Commits by Jane Smith since 3 days ago:

  e9f0b2a Update API documentation (1 day ago) [Jane Smith]
  c4d2a1b Add pagination to search endpoint (2 days ago) [Jane Smith]

2 commits found.

Implementing a git-changelog Command

Generating a changelog from commit messages is a common release task. This script parses conventional commits and groups them by type.

#!/usr/bin/env bash
# git-changelog: Generate a changelog from conventional commits

FROM=${1:-$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)}
TO=${2:-HEAD}

echo "# Changelog"
echo ""
echo "## Changes from $FROM to $TO"
echo ""

# Features
FEATURES=$(git log "$FROM".."$TO" --format='- %s (%h)' --no-merges --grep='^feat' 2>/dev/null)
if [ -n "$FEATURES" ]; then
    echo "### Features"
    echo "$FEATURES"
    echo ""
fi

# Bug Fixes
FIXES=$(git log "$FROM".."$TO" --format='- %s (%h)' --no-merges --grep='^fix' 2>/dev/null)
if [ -n "$FIXES" ]; then
    echo "### Bug Fixes"
    echo "$FIXES"
    echo ""
fi

# Other
OTHERS=$(git log "$FROM".."$TO" --format='- %s (%h)' --no-merges --grep='^feat' --grep='^fix' --invert-grep 2>/dev/null)
if [ -n "$OTHERS" ]; then
    echo "### Other Changes"
    echo "$OTHERS"
    echo ""
fi
$ git changelog v1.2.0
# Changelog

## Changes from v1.2.0 to HEAD

### Features
- feat: add WebSocket support for real-time updates (a3f1c2d)
- feat: implement file upload endpoint (8b2e4f1)

### Bug Fixes
- fix: resolve memory leak in connection pool (1d5a7c3)
- fix: correct timezone handling in scheduler (e9f0b2a)

### Other Changes
- docs: update API reference for v1.3 (c4d2a1b)
- chore: upgrade express to 4.19.2 (f7a3b2c)

Building Git Commands in Node.js

Bash scripts work for simple commands, but Node.js gives you structured data handling, colored output libraries, argument parsing, and testability. Here is the pattern I use.

Parsing Git Output

#!/usr/bin/env node
// git-branch-info: Show detailed branch information

var childProcess = require('child_process');

function exec(cmd) {
    try {
        return childProcess.execSync(cmd, { encoding: 'utf8' }).trim();
    } catch (err) {
        return '';
    }
}

function getBranches() {
    var raw = exec('git branch -vv --format="%(refname:short)|%(upstream:short)|%(upstream:track)|%(committerdate:relative)|%(subject)"');
    if (!raw) return [];

    return raw.split('\n').map(function (line) {
        var parts = line.split('|');
        return {
            name: parts[0],
            upstream: parts[1] || 'none',
            track: parts[2] || '',
            date: parts[3],
            subject: parts[4]
        };
    });
}

function formatBranch(branch) {
    var status = '';
    if (branch.track.indexOf('ahead') !== -1) status = '\x1b[32m' + branch.track + '\x1b[0m';
    else if (branch.track.indexOf('behind') !== -1) status = '\x1b[31m' + branch.track + '\x1b[0m';
    else if (branch.track === '[gone]') status = '\x1b[33m[gone]\x1b[0m';

    var line = '  \x1b[36m' + padRight(branch.name, 30) + '\x1b[0m';
    line += '\x1b[32m' + padRight(branch.date, 20) + '\x1b[0m';
    line += padRight(status, 25);
    line += branch.subject;
    return line;
}

function padRight(str, len) {
    while (str.length < len) str += ' ';
    return str;
}

var branches = getBranches();
console.log('\n  Branch' + new Array(26).join(' ') + 'Last Commit' + new Array(12).join(' ') + 'Status');
console.log('  ' + new Array(90).join('-'));
branches.forEach(function (branch) {
    console.log(formatBranch(branch));
});
console.log('');
$ git branch-info

  Branch                        Last Commit         Status
  -----------------------------------------------------------------------------------------
  main                          2 hours ago         [ahead 1]
  feature/auth-refactor         5 hours ago         [ahead 3, behind 1]
  fix/memory-leak               1 day ago           [gone]
  feature/dashboard             3 days ago

Formatting Results with Colors

var COLORS = {
    reset: '\x1b[0m',
    red: '\x1b[31m',
    green: '\x1b[32m',
    yellow: '\x1b[33m',
    blue: '\x1b[34m',
    cyan: '\x1b[36m',
    dim: '\x1b[2m',
    bold: '\x1b[1m'
};

function colorize(text, color) {
    if (!process.stdout.isTTY) return text;
    return COLORS[color] + text + COLORS.reset;
}

function header(text) {
    console.log('');
    console.log(colorize(text, 'bold'));
    console.log(colorize(new Array(text.length + 1).join('─'), 'dim'));
}

function success(text) {
    console.log(colorize('  ✓ ', 'green') + text);
}

function warning(text) {
    console.log(colorize('  ⚠ ', 'yellow') + text);
}

function error(text) {
    console.log(colorize('  ✗ ', 'red') + text);
}

Implementing a git-stats Command (Repository Analytics)

This is a more complex Node.js command that gives you a snapshot of repository health and activity.

#!/usr/bin/env node
// git-stats: Repository analytics dashboard

var childProcess = require('child_process');

function exec(cmd) {
    try {
        return childProcess.execSync(cmd, { encoding: 'utf8' }).trim();
    } catch (err) {
        return '';
    }
}

function getRepoStats() {
    var stats = {};

    // Basic counts
    stats.totalCommits = parseInt(exec('git rev-list --count HEAD') || '0', 10);
    stats.totalBranches = exec('git branch -a').split('\n').filter(Boolean).length;
    stats.totalTags = exec('git tag').split('\n').filter(Boolean).length;
    stats.totalContributors = parseInt(exec('git shortlog -sn --no-merges HEAD | wc -l') || '0', 10);

    // Recent activity
    stats.commitsThisWeek = parseInt(exec('git rev-list --count --since="1 week ago" HEAD') || '0', 10);
    stats.commitsThisMonth = parseInt(exec('git rev-list --count --since="1 month ago" HEAD') || '0', 10);

    // Repository size
    var sizeOutput = exec('git count-objects -vH');
    var sizeMatch = sizeOutput.match(/size-pack:\s+(.+)/);
    stats.repoSize = sizeMatch ? sizeMatch[1] : 'unknown';

    // Top contributors (last 90 days)
    var contribRaw = exec('git shortlog -sne --no-merges --since="90 days ago" HEAD');
    stats.topContributors = contribRaw.split('\n').filter(Boolean).slice(0, 5).map(function (line) {
        var match = line.trim().match(/^(\d+)\s+(.+)$/);
        return match ? { commits: parseInt(match[1], 10), name: match[2] } : null;
    }).filter(Boolean);

    // Most changed files (last 30 days)
    var filesRaw = exec('git log --since="30 days ago" --name-only --format="" HEAD');
    var fileCounts = {};
    filesRaw.split('\n').filter(Boolean).forEach(function (file) {
        fileCounts[file] = (fileCounts[file] || 0) + 1;
    });
    stats.hotFiles = Object.keys(fileCounts)
        .map(function (file) { return { file: file, changes: fileCounts[file] }; })
        .sort(function (a, b) { return b.changes - a.changes; })
        .slice(0, 5);

    // Branch age
    var branchRaw = exec('git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:relative)" refs/heads/');
    stats.staleBranches = branchRaw.split('\n').filter(function (line) {
        return line.indexOf('month') !== -1 || line.indexOf('year') !== -1;
    }).length;

    return stats;
}

function printStats(stats) {
    console.log('');
    console.log('\x1b[1m  Repository Analytics\x1b[0m');
    console.log('\x1b[2m  ' + new Array(50).join('─') + '\x1b[0m');
    console.log('');
    console.log('  Total Commits:       \x1b[36m' + stats.totalCommits + '\x1b[0m');
    console.log('  Total Branches:      \x1b[36m' + stats.totalBranches + '\x1b[0m');
    console.log('  Total Tags:          \x1b[36m' + stats.totalTags + '\x1b[0m');
    console.log('  Contributors:        \x1b[36m' + stats.totalContributors + '\x1b[0m');
    console.log('  Repo Size:           \x1b[36m' + stats.repoSize + '\x1b[0m');
    console.log('  Stale Branches:      \x1b[' + (stats.staleBranches > 5 ? '31' : '33') + 'm' + stats.staleBranches + '\x1b[0m');
    console.log('');
    console.log('  \x1b[1mActivity\x1b[0m');
    console.log('  Commits this week:   \x1b[32m' + stats.commitsThisWeek + '\x1b[0m');
    console.log('  Commits this month:  \x1b[32m' + stats.commitsThisMonth + '\x1b[0m');
    console.log('');

    if (stats.topContributors.length > 0) {
        console.log('  \x1b[1mTop Contributors (90 days)\x1b[0m');
        stats.topContributors.forEach(function (c) {
            var bar = new Array(Math.min(c.commits, 30) + 1).join('█');
            console.log('  \x1b[36m' + padRight(c.name, 30) + '\x1b[0m \x1b[32m' + bar + '\x1b[0m ' + c.commits);
        });
        console.log('');
    }

    if (stats.hotFiles.length > 0) {
        console.log('  \x1b[1mMost Changed Files (30 days)\x1b[0m');
        stats.hotFiles.forEach(function (f) {
            console.log('  \x1b[33m' + padRight(String(f.changes), 5) + '\x1b[0m ' + f.file);
        });
        console.log('');
    }
}

function padRight(str, len) {
    while (str.length < len) str += ' ';
    return str;
}

var stats = getRepoStats();
printStats(stats);
$ git stats

  Repository Analytics
  ──────────────────────────────────────────────────

  Total Commits:       1,247
  Total Branches:      23
  Total Tags:          18
  Contributors:        8
  Repo Size:           12.4 MiB
  Stale Branches:      4

  Activity
  Commits this week:   34
  Commits this month:  142

  Top Contributors (90 days)
  Shane Larson <[email protected]>  ██████████████████████ 67
  Jane Smith <[email protected]>     ████████████████ 48
  Alex Chen <[email protected]>      █████████ 27

  Most Changed Files (30 days)
  23    src/routes/api.js
  18    src/middleware/auth.js
  14    test/api.test.js
  11    package.json
  9     src/models/user.js

Distributing Custom Commands via npm Packages

The cleanest way to distribute Git commands to a team is through npm. The bin field in package.json maps command names to scripts, and npm creates the symlinks when you install.

Package Structure

git-tools/
├── package.json
├── bin/
│   ├── git-recent
│   ├── git-cleanup
│   ├── git-changelog
│   └── git-stats
├── lib/
│   ├── exec.js
│   ├── colors.js
│   └── format.js
└── completions/
    ├── git-recent.bash
    └── git-stats.bash

package.json

{
  "name": "@myorg/git-tools",
  "version": "1.0.0",
  "description": "Custom Git commands for the team",
  "bin": {
    "git-recent": "./bin/git-recent",
    "git-cleanup": "./bin/git-cleanup",
    "git-changelog": "./bin/git-changelog",
    "git-stats": "./bin/git-stats"
  },
  "scripts": {
    "postinstall": "node ./scripts/install-completions.js"
  },
  "keywords": ["git", "cli", "developer-tools"],
  "license": "MIT"
}

Shared Utility Module

// lib/exec.js
var childProcess = require('child_process');

function exec(cmd, options) {
    var opts = Object.assign({ encoding: 'utf8', stdio: 'pipe' }, options || {});
    try {
        return childProcess.execSync(cmd, opts).trim();
    } catch (err) {
        if (opts.throwOnError) throw err;
        return '';
    }
}

function execLines(cmd) {
    var output = exec(cmd);
    if (!output) return [];
    return output.split('\n').filter(Boolean);
}

function isGitRepo() {
    try {
        childProcess.execSync('git rev-parse --is-inside-work-tree', {
            encoding: 'utf8',
            stdio: 'pipe'
        });
        return true;
    } catch (err) {
        return false;
    }
}

function getCurrentBranch() {
    return exec('git rev-parse --abbrev-ref HEAD');
}

function getDefaultBranch() {
    var remote = exec('git remote show origin 2>/dev/null | grep "HEAD branch" | sed "s/.*: //"');
    return remote || 'main';
}

module.exports = {
    exec: exec,
    execLines: execLines,
    isGitRepo: isGitRepo,
    getCurrentBranch: getCurrentBranch,
    getDefaultBranch: getDefaultBranch
};

Install globally for the whole team:

$ npm install -g @myorg/git-tools

# Now all commands are available
$ git recent
$ git stats
$ git cleanup
$ git changelog

Or install from a private registry or GitHub:

$ npm install -g git+https://github.com/myorg/git-tools.git

Tab Completion for Custom Commands

Tab completion makes custom commands feel like first-class Git features. You register completion functions in your shell profile.

Bash Completions

# completions/git-recent.bash
_git_recent() {
    local cur=${COMP_WORDS[COMP_CWORD]}
    COMPREPLY=($(compgen -W "5 10 20 50" -- "$cur"))
}

# completions/git-cleanup.bash
_git_cleanup() {
    local cur=${COMP_WORDS[COMP_CWORD]}
    local branches=$(git branch --format='%(refname:short)' 2>/dev/null)
    COMPREPLY=($(compgen -W "$branches" -- "$cur"))
}

# completions/git-stats.bash
_git_stats() {
    local cur=${COMP_WORDS[COMP_CWORD]}
    COMPREPLY=($(compgen -W "--json --no-color --since --contributors --files" -- "$cur"))
}

Installation Script

// scripts/install-completions.js
var fs = require('fs');
var path = require('path');
var os = require('os');

var completionsDir = path.join(__dirname, '..', 'completions');
var bashrcPath = path.join(os.homedir(), '.bashrc');

function installCompletions() {
    if (!fs.existsSync(completionsDir)) return;

    var files = fs.readdirSync(completionsDir).filter(function (f) {
        return f.endsWith('.bash');
    });

    if (files.length === 0) return;

    var marker = '# git-tools completions';
    var sourceLine = marker + '\n' + files.map(function (f) {
        return 'source "' + path.join(completionsDir, f) + '"';
    }).join('\n') + '\n# end git-tools completions';

    try {
        var bashrc = fs.existsSync(bashrcPath) ? fs.readFileSync(bashrcPath, 'utf8') : '';
        if (bashrc.indexOf(marker) !== -1) {
            console.log('Completions already installed.');
            return;
        }
        fs.appendFileSync(bashrcPath, '\n' + sourceLine + '\n');
        console.log('Tab completions installed. Run: source ~/.bashrc');
    } catch (err) {
        console.log('Could not install completions automatically.');
        console.log('Add the following to your ~/.bashrc:');
        console.log(sourceLine);
    }
}

installCompletions();
$ git stats --<TAB>
--json  --no-color  --since  --contributors  --files

$ git cleanup <TAB>
feature/auth  fix/timeout  chore/deps  main

Chaining Git Commands with Complex Workflows

Real workflows often involve multiple Git operations in sequence. Custom commands can orchestrate these safely.

#!/usr/bin/env node
// git-ship: Prepare and merge a feature branch

var gitExec = require('../lib/exec');

function ship() {
    var branch = gitExec.getCurrentBranch();
    var defaultBranch = gitExec.getDefaultBranch();

    if (branch === defaultBranch) {
        console.error('Error: Cannot ship from ' + defaultBranch + '. Switch to a feature branch.');
        process.exit(1);
    }

    console.log('Shipping branch: ' + branch);
    console.log('');

    // Step 1: Make sure working directory is clean
    var status = gitExec.exec('git status --porcelain');
    if (status) {
        console.error('Error: Working directory is not clean. Commit or stash changes first.');
        process.exit(1);
    }

    // Step 2: Fetch latest and rebase onto default branch
    console.log('Fetching latest from origin...');
    gitExec.exec('git fetch origin', { throwOnError: true });

    console.log('Rebasing onto ' + defaultBranch + '...');
    try {
        gitExec.exec('git rebase origin/' + defaultBranch, { throwOnError: true });
    } catch (err) {
        console.error('Rebase failed. Resolve conflicts and try again.');
        gitExec.exec('git rebase --abort');
        process.exit(1);
    }

    // Step 3: Run tests
    console.log('Running tests...');
    try {
        gitExec.exec('npm test', { throwOnError: true, stdio: 'inherit' });
    } catch (err) {
        console.error('Tests failed. Fix them before shipping.');
        process.exit(1);
    }

    // Step 4: Push and create PR
    console.log('Pushing to origin...');
    gitExec.exec('git push origin ' + branch + ' --force-with-lease', { throwOnError: true });

    console.log('');
    console.log('Branch ' + branch + ' is ready to merge.');
    console.log('Create PR: git pr create');
}

ship();
$ git ship
Shipping branch: feature/auth-refactor

Fetching latest from origin...
Rebasing onto main...
Running tests...

  42 passing (3s)

Pushing to origin...

Branch feature/auth-refactor is ready to merge.
Create PR: git pr create

Sharing Team Aliases via a Shared Gitconfig Include

Git supports [include] directives that let you share a gitconfig file across a team. Check a .gitconfig-shared file into your repository, and have team members include it.

# .gitconfig-shared (checked into repo root)
[alias]
    deploy-staging = !bash -c 'git push origin HEAD:staging && echo "Deployed to staging"'
    deploy-prod = !bash -c 'git tag -a "release-$(date +%Y%m%d-%H%M)" -m "Production release" && git push origin --tags && echo "Tagged and pushed release"'
    hotfix = !bash -c 'git checkout -b hotfix/$1 main && echo "Hotfix branch created"' -
    sync = !git fetch --all --prune && git pull --rebase origin $(git rev-parse --abbrev-ref HEAD)
    pr = !git push -u origin $(git rev-parse --abbrev-ref HEAD) && echo "Branch pushed. Open PR at: $(git remote get-url origin | sed 's/.git$//')/compare/$(git rev-parse --abbrev-ref HEAD)"

Each team member adds one line to their ~/.gitconfig:

[include]
    path = /path/to/repo/.gitconfig-shared

Or automate it in your project setup script:

#!/usr/bin/env bash
# setup.sh: Configure team Git aliases

REPO_ROOT=$(git rev-parse --show-toplevel)
SHARED_CONFIG="$REPO_ROOT/.gitconfig-shared"

if [ -f "$SHARED_CONFIG" ]; then
    git config --global --get-all include.path | grep -q "$SHARED_CONFIG" || \
        git config --global --add include.path "$SHARED_CONFIG"
    echo "Team Git aliases loaded from $SHARED_CONFIG"
fi

Complete Working Example

Here is a complete npm package containing four Node.js-powered Git commands. This is what I actually ship to teams.

Project Setup

$ mkdir git-team-tools && cd git-team-tools
$ npm init -y

bin/git-recent (Full Implementation)

#!/usr/bin/env node
// git-recent: Show recently visited branches with commit info

var childProcess = require('child_process');
var args = process.argv.slice(2);
var count = parseInt(args[0], 10) || 10;

function exec(cmd) {
    try { return childProcess.execSync(cmd, { encoding: 'utf8' }).trim(); }
    catch (e) { return ''; }
}

function color(text, code) {
    if (!process.stdout.isTTY) return text;
    return '\x1b[' + code + 'm' + text + '\x1b[0m';
}

function padRight(str, len) {
    str = String(str);
    while (str.length < len) str += ' ';
    return str.substring(0, len);
}

var reflog = exec('git reflog show --format="%gs" -n 500');
var branches = [];
var seen = {};

reflog.split('\n').forEach(function (line) {
    var match = line.match(/checkout: moving from .+ to (.+)/);
    if (match && !seen[match[1]]) {
        seen[match[1]] = true;
        branches.push(match[1]);
    }
});

branches = branches.slice(0, count);

console.log('');
console.log(color('  Recent Branches', '1'));
console.log(color('  ' + new Array(70).join('─'), '2'));

branches.forEach(function (branch) {
    var exists = exec('git rev-parse --verify ' + branch + ' 2>/dev/null');
    if (!exists) return;

    var date = exec('git log -1 --format="%ar" ' + branch);
    var hash = exec('git log -1 --format="%h" ' + branch);
    var subject = exec('git log -1 --format="%s" ' + branch);

    console.log(
        '  ' + color(padRight(branch, 28), '36') +
        color(padRight(date, 18), '32') +
        color(hash, '33') + ' ' +
        subject.substring(0, 40)
    );
});

console.log('');

bin/git-cleanup (Full Implementation)

#!/usr/bin/env node
// git-cleanup: Remove merged branches and prune stale remotes

var childProcess = require('child_process');
var readline = require('readline');

function exec(cmd) {
    try { return childProcess.execSync(cmd, { encoding: 'utf8' }).trim(); }
    catch (e) { return ''; }
}

function color(text, code) {
    if (!process.stdout.isTTY) return text;
    return '\x1b[' + code + 'm' + text + '\x1b[0m';
}

var defaultBranch = exec('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null')
    .replace('refs/remotes/origin/', '') || 'main';

console.log('');
console.log(color('  Git Cleanup', '1'));
console.log(color('  ' + new Array(50).join('─'), '2'));
console.log('');

// Prune remote tracking branches
console.log('  Pruning remote tracking branches...');
exec('git fetch --prune');
console.log(color('  ✓ Remote branches pruned', '32'));

// Find merged branches
var merged = exec('git branch --merged ' + defaultBranch);
var toDelete = merged.split('\n')
    .map(function (b) { return b.trim(); })
    .filter(function (b) {
        return b && b !== defaultBranch && b !== 'master' && b !== 'main' && b.charAt(0) !== '*';
    });

if (toDelete.length === 0) {
    console.log(color('  ✓ No merged branches to clean up', '32'));
    console.log('');
    process.exit(0);
}

console.log('');
console.log('  Branches merged into ' + color(defaultBranch, '36') + ':');
toDelete.forEach(function (branch) {
    var date = exec('git log -1 --format="%ar" ' + branch);
    console.log('    ' + color('•', '31') + ' ' + branch + color(' (' + date + ')', '2'));
});
console.log('');

var rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question('  Delete these ' + toDelete.length + ' branches? (y/N) ', function (answer) {
    if (answer.toLowerCase() === 'y') {
        toDelete.forEach(function (branch) {
            exec('git branch -d ' + branch);
            console.log(color('  ✓ Deleted ' + branch, '32'));
        });
    } else {
        console.log(color('  Aborted.', '33'));
    }
    console.log('');
    rl.close();
});

bin/git-changelog (Full Implementation)

#!/usr/bin/env node
// git-changelog: Generate a formatted changelog from commits

var childProcess = require('child_process');
var args = process.argv.slice(2);

function exec(cmd) {
    try { return childProcess.execSync(cmd, { encoding: 'utf8' }).trim(); }
    catch (e) { return ''; }
}

var from = args[0] || exec('git describe --tags --abbrev=0 2>/dev/null') || exec('git rev-list --max-parents=0 HEAD');
var to = args[1] || 'HEAD';
var format = args.indexOf('--json') !== -1 ? 'json' : 'markdown';

var raw = exec('git log ' + from + '..' + to + ' --format="%h|%s|%an|%ar" --no-merges');
if (!raw) {
    console.log('No commits found between ' + from + ' and ' + to);
    process.exit(0);
}

var commits = raw.split('\n').map(function (line) {
    var parts = line.split('|');
    var subject = parts[1] || '';
    var type = 'other';
    var typeMatch = subject.match(/^(\w+)(\(.+?\))?:/);
    if (typeMatch) type = typeMatch[1];

    return {
        hash: parts[0],
        subject: subject,
        author: parts[2],
        date: parts[3],
        type: type
    };
});

var groups = {};
commits.forEach(function (commit) {
    var key = commit.type;
    if (!groups[key]) groups[key] = [];
    groups[key].push(commit);
});

var typeLabels = {
    feat: 'Features',
    fix: 'Bug Fixes',
    docs: 'Documentation',
    refactor: 'Refactoring',
    test: 'Tests',
    chore: 'Chores',
    perf: 'Performance',
    style: 'Style',
    ci: 'CI/CD',
    other: 'Other Changes'
};

if (format === 'json') {
    console.log(JSON.stringify({ from: from, to: to, groups: groups }, null, 2));
} else {
    console.log('# Changelog\n');
    console.log('## ' + from + ' → ' + to + '\n');

    var order = ['feat', 'fix', 'perf', 'refactor', 'docs', 'test', 'chore', 'ci', 'style', 'other'];
    order.forEach(function (type) {
        if (!groups[type]) return;
        console.log('### ' + (typeLabels[type] || type) + '\n');
        groups[type].forEach(function (commit) {
            console.log('- ' + commit.subject + ' (' + commit.hash + ')');
        });
        console.log('');
    });
}

bin/git-stats (Full Implementation)

#!/usr/bin/env node
// git-stats: Repository analytics and health dashboard

var childProcess = require('child_process');

function exec(cmd) {
    try { return childProcess.execSync(cmd, { encoding: 'utf8' }).trim(); }
    catch (e) { return ''; }
}

function color(text, code) {
    if (!process.stdout.isTTY) return text;
    return '\x1b[' + code + 'm' + text + '\x1b[0m';
}

function padRight(str, len) {
    str = String(str);
    while (str.length < len) str += ' ';
    return str.substring(0, len);
}

var totalCommits = exec('git rev-list --count HEAD') || '0';
var branchCount = exec('git branch').split('\n').filter(Boolean).length;
var tagCount = exec('git tag').split('\n').filter(Boolean).length;
var weekCommits = exec('git rev-list --count --since="1 week ago" HEAD') || '0';
var monthCommits = exec('git rev-list --count --since="1 month ago" HEAD') || '0';
var firstCommit = exec('git log --reverse --format="%ar" | head -1');
var lastCommit = exec('git log -1 --format="%ar"');

console.log('');
console.log(color('  Repository Stats', '1'));
console.log(color('  ' + new Array(55).join('─'), '2'));
console.log('');
console.log('  Total commits:      ' + color(totalCommits, '36'));
console.log('  Branches:           ' + color(String(branchCount), '36'));
console.log('  Tags:               ' + color(String(tagCount), '36'));
console.log('  First commit:       ' + color(firstCommit, '32'));
console.log('  Last commit:        ' + color(lastCommit, '32'));
console.log('  Commits (week):     ' + color(weekCommits, '33'));
console.log('  Commits (month):    ' + color(monthCommits, '33'));
console.log('');

// Top contributors
var contribs = exec('git shortlog -sne --no-merges HEAD');
var contribLines = contribs.split('\n').filter(Boolean).slice(0, 8);

if (contribLines.length > 0) {
    console.log(color('  Top Contributors', '1'));
    console.log(color('  ' + new Array(55).join('─'), '2'));
    var maxCommits = 0;
    var parsed = contribLines.map(function (line) {
        var match = line.trim().match(/^(\d+)\s+(.+)$/);
        if (!match) return null;
        var count = parseInt(match[1], 10);
        if (count > maxCommits) maxCommits = count;
        return { commits: count, name: match[2] };
    }).filter(Boolean);

    parsed.forEach(function (c) {
        var barLen = Math.max(1, Math.round((c.commits / maxCommits) * 25));
        var bar = new Array(barLen + 1).join('█');
        console.log('  ' + padRight(c.name, 32) + color(bar, '32') + ' ' + c.commits);
    });
    console.log('');
}

// Hot files
var filesRaw = exec('git log --since="30 days ago" --name-only --format="" HEAD');
var fileCounts = {};
filesRaw.split('\n').filter(Boolean).forEach(function (f) {
    fileCounts[f] = (fileCounts[f] || 0) + 1;
});

var hotFiles = Object.keys(fileCounts)
    .map(function (f) { return { file: f, count: fileCounts[f] }; })
    .sort(function (a, b) { return b.count - a.count; })
    .slice(0, 8);

if (hotFiles.length > 0) {
    console.log(color('  Hotspots (30 days)', '1'));
    console.log(color('  ' + new Array(55).join('─'), '2'));
    hotFiles.forEach(function (f) {
        console.log('  ' + color(padRight(String(f.count), 5), '33') + f.file);
    });
    console.log('');
}

package.json (Final)

{
  "name": "@myorg/git-team-tools",
  "version": "1.0.0",
  "description": "Custom Git commands for development teams",
  "bin": {
    "git-recent": "./bin/git-recent",
    "git-cleanup": "./bin/git-cleanup",
    "git-changelog": "./bin/git-changelog",
    "git-stats": "./bin/git-stats"
  },
  "keywords": ["git", "cli", "developer-tools", "productivity"],
  "license": "MIT",
  "engines": {
    "node": ">=14.0.0"
  }
}

Installation and Usage

# Install globally
$ npm install -g @myorg/git-team-tools

# Or link during development
$ cd git-team-tools
$ npm link

# Use anywhere
$ git recent
$ git cleanup
$ git changelog v1.0.0
$ git changelog --json > CHANGELOG.json
$ git stats

Common Issues and Troubleshooting

1. Permission Denied When Running Custom Command

$ git cleanup
fatal: cannot exec 'git-cleanup': Permission denied

The script file is not executable. Fix it:

$ chmod +x /usr/local/bin/git-cleanup
# Or for npm-installed commands:
$ chmod +x ./bin/git-cleanup

On Windows, this is usually not an issue since the shebang is handled by Git Bash. But if you see this on Windows, make sure the file has the correct line endings (LF, not CRLF) and the shebang line is correct.

2. Command Not Found After npm Install

$ git stats
git: 'stats' is not a git command. See 'git --help'.

npm did not create the symlink, or the npm global bin directory is not on your PATH. Check:

$ npm bin -g
/usr/local/lib/node_modules/.bin

$ echo $PATH | tr ':' '\n' | grep npm
# If empty, add to your shell profile:
$ export PATH="$(npm bin -g):$PATH"

Also verify the bin field in package.json is correct and that the file names match exactly (case-sensitive).

3. Node.js Shebang Not Recognized

$ git recent
/usr/bin/env: 'node': No such file or directory

Node.js is not installed or not on the PATH. This commonly happens in restricted environments or when using nvm without loading it in non-interactive shells:

# Check if node is available
$ which node
$ node --version

# If using nvm, add to ~/.bashrc (not just ~/.bash_profile):
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

4. Git Alias Conflicts with Custom Command

$ git recent
# Runs the alias, not your custom script

Git resolves aliases before searching PATH for custom commands. If you have both an alias and a git-recent executable, the alias wins. Remove the alias:

$ git config --global --unset alias.recent

Or check which one is active:

$ git config --get alias.recent
# If this returns something, the alias exists and takes priority

5. Encoding Issues in Commit Messages

$ git changelog
Error: Invalid character in commit message at position 42

Non-ASCII characters in commit messages can break simple string splitting. Use --encoding=UTF-8 in your git log commands, and ensure your Node.js script handles encoding properly:

var raw = exec('git log --format="%h|%s" --encoding=UTF-8');

Best Practices

  • Start with aliases, graduate to scripts. Simple shortcuts belong in .gitconfig. Once you need conditionals, error handling, or multiple steps, move to an executable script. Do not over-engineer aliases with complex shell one-liners.

  • Always check for a clean working directory. Before any command that modifies branches or history, verify git status --porcelain returns empty output. Failing to do this leads to lost work.

  • Use --force-with-lease instead of --force. If your custom command pushes to a remote, always use --force-with-lease. It prevents overwriting commits that someone else pushed since your last fetch.

  • Support --help in every custom command. Even internal tools should print usage when invoked with --help or no arguments where arguments are required. Your future self will thank you.

  • Handle non-TTY environments gracefully. Strip ANSI color codes when process.stdout.isTTY is false. This makes your commands work in CI pipelines and when piped to other tools.

  • Use execSync with stdio: 'pipe' by default. The default stdio: 'inherit' forwards output directly to the terminal, which makes it impossible to capture and process. Only use inherit when you intentionally want pass-through output (like running npm test).

  • Fail loudly with clear error messages. If a command requires being inside a Git repo, check early and exit with a meaningful message. Silent failures are the worst kind.

  • Version your team's shared gitconfig. Check .gitconfig-shared into the repository so it is versioned alongside the code. Include setup instructions in your project README.

  • Test custom commands in a throwaway repo. Create a temp repo with git init /tmp/test-repo, make some dummy commits, and test your commands there before deploying to your team.

References

Powered by Contentful