A comprehensive TypeScript library providing secure Git operations, process execution utilities, and NPM link management for automation workflows.
@grunnverk/git-tools is a production-ready library designed for building Git automation tools. It provides secure command execution primitives and high-level Git operations with a focus on safety, reliability, and ease of use.
Key Features:
- 🔒 Secure Process Execution - Shell injection prevention with validated arguments
- 🌳 Comprehensive Git Operations - 20+ Git utilities for branch management, versioning, and status queries
- 🏷️ Semantic Version Support - Intelligent tag finding and version comparison for release automation
- 🔄 Branch Management - Sync checking, safe syncing, and detailed status queries
- 🔗 NPM Link Management - Link detection, compatibility checking, and problem diagnosis for monorepo workflows
- 📝 Flexible Logging - Bring your own logger (Winston, Pino, etc.) or use the built-in console logger
- ✅ Runtime Validation - Type-safe JSON parsing and validation utilities
- 🧪 Well-Tested - Comprehensive test coverage for reliability
npm install @grunnverk/git-tools- Node.js 14 or higher
- Git 2.0 or higher
- TypeScript 4.5+ (for TypeScript projects)
# If you want to use Winston for logging
npm install winstonimport {
getCurrentBranch,
getGitStatusSummary,
findPreviousReleaseTag,
runSecure
} from '@grunnverk/git-tools';
// Get current branch
const branch = await getCurrentBranch();
console.log(`Current branch: ${branch}`);
// Get comprehensive status
const status = await getGitStatusSummary();
console.log(`Status: ${status.status}`);
console.log(`Unstaged files: ${status.unstagedCount}`);
console.log(`Uncommitted changes: ${status.uncommittedCount}`);
console.log(`Unpushed commits: ${status.unpushedCount}`);
// Find previous release tag
const previousTag = await findPreviousReleaseTag('1.2.3', 'v*');
console.log(`Previous release: ${previousTag}`);
// Execute Git commands securely
const { stdout } = await runSecure('git', ['log', '--oneline', '-n', '5']);
console.log('Recent commits:', stdout);All process execution functions prioritize security by preventing shell injection attacks:
import { runSecure, run } from '@grunnverk/git-tools';
// ✅ SECURE: Uses argument array, no shell interpretation
const { stdout } = await runSecure('git', ['log', '--format=%s', userInput]);
// ⚠️ LESS SECURE: Uses shell command string
const result = await run(`git log --format=%s ${userInput}`);Best Practice: Always use runSecure or runSecureWithDryRunSupport for user input.
By default, git-tools uses a console-based logger. You can integrate your own logger:
import { setLogger } from '@grunnverk/git-tools';
import winston from 'winston';
// Create Winston logger
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'git-tools.log' })
]
});
// Set global logger for git-tools
setLogger(logger);
// Now all git-tools operations will use your logger
const branch = await getCurrentBranch(); // Logs via WinstonMany automation workflows need dry-run capability:
import { runSecureWithDryRunSupport } from '@grunnverk/git-tools';
const isDryRun = process.env.DRY_RUN === 'true';
// This will only log what would happen if isDryRun is true
const result = await runSecureWithDryRunSupport(
'git',
['push', 'origin', 'main'],
isDryRun
);import {
getCurrentBranch,
localBranchExists,
remoteBranchExists,
isBranchInSyncWithRemote
} from '@grunnverk/git-tools';
// Get current branch
const currentBranch = await getCurrentBranch();
console.log(`On branch: ${currentBranch}`);
// Check if branches exist
const hasMain = await localBranchExists('main');
const hasRemoteMain = await remoteBranchExists('main', 'origin');
console.log(`Local main exists: ${hasMain}`);
console.log(`Remote main exists: ${hasRemoteMain}`);
// Check if local and remote are in sync
const syncStatus = await isBranchInSyncWithRemote('main');
console.log(`In sync: ${syncStatus.inSync}`);
console.log(`Local SHA: ${syncStatus.localSha}`);
console.log(`Remote SHA: ${syncStatus.remoteSha}`);import { safeSyncBranchWithRemote } from '@grunnverk/git-tools';
// Safely sync branch with remote (handles conflicts gracefully)
const result = await safeSyncBranchWithRemote('main', 'origin');
if (result.success) {
console.log('Branch successfully synced with remote');
} else if (result.conflictResolutionRequired) {
console.error('Conflict resolution required:', result.error);
// Handle conflicts manually
} else {
console.error('Sync failed:', result.error);
}import { getGitStatusSummary } from '@grunnverk/git-tools';
const status = await getGitStatusSummary();
console.log(`Branch: ${status.branch}`);
console.log(`Status: ${status.status}`); // e.g., "2 unstaged, 1 uncommitted, 3 unpushed"
// Individual status flags
if (status.hasUnstagedFiles) {
console.log(`⚠️ ${status.unstagedCount} unstaged files`);
}
if (status.hasUncommittedChanges) {
console.log(`📝 ${status.uncommittedCount} uncommitted changes`);
}
if (status.hasUnpushedCommits) {
console.log(`⬆️ ${status.unpushedCount} unpushed commits`);
}
if (status.status === 'clean') {
console.log('✅ Working directory clean');
}import { isGitRepository } from '@grunnverk/git-tools';
const isRepo = await isGitRepository('/path/to/directory');
if (isRepo) {
console.log('This is a Git repository');
} else {
console.log('Not a Git repository');
}Useful for generating release notes or comparing versions:
import { findPreviousReleaseTag, getCurrentVersion } from '@grunnverk/git-tools';
// Get current version from package.json
const currentVersion = await getCurrentVersion();
console.log(`Current version: ${currentVersion}`);
// Find previous release tag
// Looks for tags matching "v*" pattern that are < current version
const previousTag = await findPreviousReleaseTag(currentVersion, 'v*');
if (previousTag) {
console.log(`Previous release: ${previousTag}`);
// Now you can generate release notes from previousTag..HEAD
} else {
console.log('No previous release found (possibly first release)');
}import { findPreviousReleaseTag } from '@grunnverk/git-tools';
// Standard version tags (v1.0.0, v1.2.3)
const prevRelease = await findPreviousReleaseTag('1.2.3', 'v*');
// Working branch tags (working/v1.0.0)
const prevWorking = await findPreviousReleaseTag('1.2.3', 'working/v*');
// Custom prefix tags (release/v1.0.0)
const prevCustom = await findPreviousReleaseTag('1.2.3', 'release/v*');import { getDefaultFromRef } from '@grunnverk/git-tools';
// Intelligently determines the best reference for release comparisons
// Tries: previous tag -> main -> master -> origin/main -> origin/master
const fromRef = await getDefaultFromRef(false, 'working');
console.log(`Compare from: ${fromRef}`);
// Force main branch (skip tag detection)
const mainRef = await getDefaultFromRef(true);
console.log(`Compare from main: ${mainRef}`);Perfect for monorepo development and local package testing:
import {
isNpmLinked,
getGloballyLinkedPackages,
getLinkedDependencies
} from '@grunnverk/git-tools';
// Check if a package is globally linked
const isLinked = await isNpmLinked('/path/to/my-package');
console.log(`Package is linked: ${isLinked}`);
// Get all globally linked packages
const globalPackages = await getGloballyLinkedPackages();
console.log('Globally linked packages:', Array.from(globalPackages));
// Get packages that this project is linked to (consuming)
const linkedDeps = await getLinkedDependencies('/path/to/consumer');
console.log('Consuming linked packages:', Array.from(linkedDeps));import { getLinkCompatibilityProblems } from '@grunnverk/git-tools';
// Check for version compatibility issues with linked dependencies
const problems = await getLinkCompatibilityProblems('/path/to/package');
if (problems.size > 0) {
console.error('⚠️ Link compatibility problems detected:');
for (const packageName of problems) {
console.error(` - ${packageName}`);
}
} else {
console.log('✅ All linked dependencies are compatible');
}Note: getLinkCompatibilityProblems intelligently handles prerelease versions (e.g., 4.4.53-dev.0 is compatible with ^4.4).
import { runSecure, runSecureWithInheritedStdio } from '@grunnverk/git-tools';
// Execute and capture output
const { stdout, stderr } = await runSecure('git', ['status', '--porcelain']);
console.log(stdout);
// Execute with inherited stdio (output goes directly to terminal)
await runSecureWithInheritedStdio('git', ['push', 'origin', 'main']);Some commands are expected to fail in certain scenarios:
import { runSecure } from '@grunnverk/git-tools';
try {
// Check if a branch exists without logging errors
await runSecure('git', ['rev-parse', '--verify', 'feature-branch'], {
suppressErrorLogging: true
});
console.log('Branch exists');
} catch (error) {
console.log('Branch does not exist');
}import { validateGitRef, validateFilePath } from '@grunnverk/git-tools';
const userBranch = getUserInput();
// Validate before using in commands
if (validateGitRef(userBranch)) {
await runSecure('git', ['checkout', userBranch]);
} else {
console.error('Invalid branch name');
}
const userFile = getUserInput();
if (validateFilePath(userFile)) {
await runSecure('git', ['add', userFile]);
} else {
console.error('Invalid file path');
}import { safeJsonParse, validatePackageJson } from '@grunnverk/git-tools';
// Parse JSON with automatic error handling
try {
const data = safeJsonParse(jsonString, 'config.json');
console.log(data);
} catch (error) {
console.error('Failed to parse JSON:', error.message);
}
// Validate package.json structure
try {
const packageJson = safeJsonParse(fileContents, 'package.json');
const validated = validatePackageJson(packageJson, 'package.json');
console.log(`Package: ${validated.name}`);
console.log(`Version: ${validated.version}`);
} catch (error) {
console.error('Invalid package.json:', error.message);
}import { validateString, validateHasProperty } from '@grunnverk/git-tools';
// Validate non-empty string
try {
const username = validateString(userInput, 'username');
console.log(`Valid username: ${username}`);
} catch (error) {
console.error('Invalid username:', error.message);
}
// Validate object has required property
try {
validateHasProperty(config, 'apiKey', 'config.json');
console.log('Config has required apiKey');
} catch (error) {
console.error('Missing required property:', error.message);
}import {
getCurrentVersion,
findPreviousReleaseTag,
runSecure
} from '@grunnverk/git-tools';
async function generateReleaseNotes() {
// Get version range
const currentVersion = await getCurrentVersion();
const previousTag = await findPreviousReleaseTag(currentVersion, 'v*');
if (!previousTag) {
console.log('No previous release found');
return;
}
// Get commits between tags
const { stdout } = await runSecure('git', [
'log',
`${previousTag}..HEAD`,
'--pretty=format:%s',
'--no-merges'
]);
const commits = stdout.trim().split('\n');
console.log(`Release Notes for ${currentVersion}`);
console.log(`Changes since ${previousTag}:`);
console.log('');
commits.forEach(commit => console.log(`- ${commit}`));
}
generateReleaseNotes().catch(console.error);import {
getGitStatusSummary,
isBranchInSyncWithRemote
} from '@grunnverk/git-tools';
async function validateBeforePush() {
const status = await getGitStatusSummary();
// Check for uncommitted changes
if (status.hasUnstagedFiles || status.hasUncommittedChanges) {
console.error('❌ Cannot push with uncommitted changes');
return false;
}
// Check if in sync with remote
const syncStatus = await isBranchInSyncWithRemote(status.branch);
if (!syncStatus.inSync) {
console.error('❌ Branch not in sync with remote');
console.error(`Local: ${syncStatus.localSha}`);
console.error(`Remote: ${syncStatus.remoteSha}`);
return false;
}
console.log('✅ Ready to push');
return true;
}
validateBeforePush().catch(console.error);import {
getLinkedDependencies,
getLinkCompatibilityProblems
} from '@grunnverk/git-tools';
async function checkMonorepoLinks(packageDirs: string[]) {
for (const packageDir of packageDirs) {
console.log(`\nChecking: ${packageDir}`);
const linked = await getLinkedDependencies(packageDir);
console.log(`Linked dependencies: ${Array.from(linked).join(', ') || 'none'}`);
const problems = await getLinkCompatibilityProblems(packageDir);
if (problems.size > 0) {
console.error('⚠️ Compatibility issues:');
for (const pkg of problems) {
console.error(` - ${pkg}`);
}
} else {
console.log('✅ All links compatible');
}
}
}
checkMonorepoLinks([
'./packages/core',
'./packages/cli',
'./packages/utils'
]).catch(console.error);import {
getCurrentBranch,
localBranchExists,
safeSyncBranchWithRemote
} from '@grunnverk/git-tools';
async function syncMainBranch() {
const currentBranch = await getCurrentBranch();
const hasMain = await localBranchExists('main');
if (!hasMain) {
console.error('❌ Main branch does not exist locally');
return;
}
console.log(`Current branch: ${currentBranch}`);
console.log('Syncing main branch with remote...');
const result = await safeSyncBranchWithRemote('main');
if (result.success) {
console.log('✅ Main branch synced successfully');
} else if (result.conflictResolutionRequired) {
console.error('❌ Conflict resolution required');
console.error(result.error);
} else {
console.error('❌ Sync failed:', result.error);
}
}
syncMainBranch().catch(console.error);| Function | Parameters | Returns | Description |
|---|---|---|---|
isValidGitRef(ref) |
ref: string |
Promise<boolean> |
Tests if a git reference exists and is valid |
isGitRepository(cwd?) |
cwd?: string |
Promise<boolean> |
Checks if directory is a git repository |
findPreviousReleaseTag(version, pattern?) |
version: string, pattern?: string |
Promise<string | null> |
Finds highest tag less than current version |
getCurrentVersion() |
- | Promise<string | null> |
Gets current version from package.json |
getCurrentBranch() |
- | Promise<string> |
Gets current branch name |
getDefaultFromRef(forceMain?, branch?) |
forceMain?: boolean, branch?: string |
Promise<string> |
Gets reliable default for release comparison |
getRemoteDefaultBranch(cwd?) |
cwd?: string |
Promise<string | null> |
Gets default branch name from remote |
localBranchExists(branch) |
branch: string |
Promise<boolean> |
Checks if local branch exists |
remoteBranchExists(branch, remote?) |
branch: string, remote?: string |
Promise<boolean> |
Checks if remote branch exists |
getBranchCommitSha(ref) |
ref: string |
Promise<string> |
Gets commit SHA for a branch |
isBranchInSyncWithRemote(branch, remote?) |
branch: string, remote?: string |
Promise<SyncStatus> |
Checks if local/remote branches match |
safeSyncBranchWithRemote(branch, remote?) |
branch: string, remote?: string |
Promise<SyncResult> |
Safely syncs branch with remote |
getGitStatusSummary(workingDir?) |
workingDir?: string |
Promise<GitStatus> |
Gets comprehensive git status |
getGloballyLinkedPackages() |
- | Promise<Set<string>> |
Gets globally linked npm packages |
getLinkedDependencies(packageDir) |
packageDir: string |
Promise<Set<string>> |
Gets linked dependencies for package |
getLinkCompatibilityProblems(packageDir) |
packageDir: string |
Promise<Set<string>> |
Finds version compatibility issues |
isNpmLinked(packageDir) |
packageDir: string |
Promise<boolean> |
Checks if package is globally linked |
| Function | Parameters | Returns | Description |
|---|---|---|---|
runSecure(cmd, args, opts?) |
cmd: string, args: string[], opts?: RunSecureOptions |
Promise<{stdout, stderr}> |
Securely executes command with argument array |
runSecureWithInheritedStdio(cmd, args, opts?) |
cmd: string, args: string[], opts?: SpawnOptions |
Promise<void> |
Secure execution with inherited stdio |
run(command, opts?) |
command: string, opts?: RunOptions |
Promise<{stdout, stderr}> |
Executes command string (less secure) |
runWithDryRunSupport(cmd, dryRun, opts?) |
cmd: string, dryRun: boolean, opts?: ExecOptions |
Promise<{stdout, stderr}> |
Run with dry-run support |
runSecureWithDryRunSupport(cmd, args, dryRun, opts?) |
cmd: string, args: string[], dryRun: boolean, opts?: SpawnOptions |
Promise<{stdout, stderr}> |
Secure run with dry-run support |
validateGitRef(ref) |
ref: string |
boolean |
Validates git reference for injection |
validateFilePath(path) |
path: string |
boolean |
Validates file path for injection |
escapeShellArg(arg) |
arg: string |
string |
Escapes shell arguments |
| Function | Parameters | Returns | Description |
|---|---|---|---|
setLogger(logger) |
logger: Logger |
void |
Sets the global logger instance |
getLogger() |
- | Logger |
Gets the global logger instance |
| Function | Parameters | Returns | Description |
|---|---|---|---|
safeJsonParse<T>(json, context?) |
json: string, context?: string |
T |
Safely parses JSON with error handling |
validateString(value, fieldName) |
value: any, fieldName: string |
string |
Validates non-empty string |
validateHasProperty(obj, property, context?) |
obj: any, property: string, context?: string |
void |
Validates object has property |
validatePackageJson(data, context?, requireName?) |
data: any, context?: string, requireName?: boolean |
any |
Validates package.json structure |
interface GitStatus {
branch: string;
hasUnstagedFiles: boolean;
hasUncommittedChanges: boolean;
hasUnpushedCommits: boolean;
unstagedCount: number;
uncommittedCount: number;
unpushedCount: number;
status: string;
}
interface SyncStatus {
inSync: boolean;
localSha?: string;
remoteSha?: string;
localExists: boolean;
remoteExists: boolean;
error?: string;
}
interface SyncResult {
success: boolean;
error?: string;
conflictResolutionRequired?: boolean;
}
interface Logger {
error(message: string, ...meta: any[]): void;
warn(message: string, ...meta: any[]): void;
info(message: string, ...meta: any[]): void;
verbose(message: string, ...meta: any[]): void;
debug(message: string, ...meta: any[]): void;
}
interface RunSecureOptions extends SpawnOptions {
suppressErrorLogging?: boolean;
}
interface RunOptions extends ExecOptions {
suppressErrorLogging?: boolean;
}This library prioritizes security in command execution:
All runSecure* functions use argument arrays without shell execution:
// ✅ SAFE: No shell interpretation
await runSecure('git', ['log', userInput]);
// ⚠️ UNSAFE: Shell interprets special characters
await run(`git log ${userInput}`);Git references and file paths are validated before use:
// Validates against: .., leading -, shell metacharacters
if (!validateGitRef(userRef)) {
throw new Error('Invalid git reference');
}
// Validates against: shell metacharacters
if (!validateFilePath(userPath)) {
throw new Error('Invalid file path');
}- Always use
runSecurefor user input - Validate all git references with
validateGitRef - Validate all file paths with
validateFilePath - Use
suppressErrorLoggingto avoid leaking sensitive info - Set custom logger for production environments
The library includes comprehensive test coverage:
# Run tests
npm test
# Run tests with coverage
npm run test
# Watch mode
npm run watch# Clone the repository
git clone https://github.com/grunnverk/git-tools.git
cd git-tools
# Install dependencies
npm install
# Build
npm run build
# Run tests
npm run test
# Lint
npm run lintContributions are welcome! Please ensure:
- All tests pass:
npm test - Code is linted:
npm run lint - Add tests for new features
- Update documentation for API changes
"Command failed with exit code 128"
- Check if the directory is a git repository
- Verify git is installed and accessible
- Check git configuration
"Invalid git reference"
- Ensure branch/tag names don't contain special characters
- Verify the reference exists:
git rev-parse --verify <ref>
"Branch not in sync"
- Run
git fetchto update remote refs - Use
safeSyncBranchWithRemoteto sync automatically
NPM link detection not working
- Verify package is globally linked:
npm ls -g <package-name> - Check symlinks in global node_modules:
npm prefix -g
Apache-2.0 - see LICENSE file for details.
Tim O'Brien Email: tobrien@discursive.com GitHub: @grunnverk
This library was extracted from kodrdriv, an AI-powered Git workflow automation tool that uses these utilities for:
- Automated commit message generation
- Release note creation
- Branch management
- Monorepo publishing workflows
See RELEASE_NOTES.md for version history and changes.
- 🐛 Bug Reports: GitHub Issues
- 💬 Questions: GitHub Discussions
- 📧 Email: tobrien@discursive.com