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 --porcelainreturns empty output. Failing to do this leads to lost work.Use
--force-with-leaseinstead 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
--helpin every custom command. Even internal tools should print usage when invoked with--helpor no arguments where arguments are required. Your future self will thank you.Handle non-TTY environments gracefully. Strip ANSI color codes when
process.stdout.isTTYis false. This makes your commands work in CI pipelines and when piped to other tools.Use
execSyncwithstdio: 'pipe'by default. The defaultstdio: 'inherit'forwards output directly to the terminal, which makes it impossible to capture and process. Only useinheritwhen you intentionally want pass-through output (like runningnpm 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-sharedinto 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
- Git Documentation: git-config - Official reference for aliases and includes
- Git Documentation: gitattributes - File-level Git configuration
- Pro Git Book: Git Aliases - Introduction to Git aliases
- Node.js child_process - execSync and spawn documentation
- npm package.json bin field - How npm creates command symlinks
- Bash Completion Guide - GNU reference for tab completion