Overview
The Git Tool example demonstrates how to build a practical Git workflow automation CLI with Bunli. It shows external tool integration, shell command execution, interactive prompts, and command aliases. Location:examples/git-tool/
Quick Start
cd examples/git-tool
bun install
# Run commands
bun cli.ts branch --name feature/new-feature --switch
bun cli.ts pr --title "Add new feature" --base main
bun cli.ts sync --rebase
bun cli.ts status --detailed
Project Structure
git-tool/
├── cli.ts # CLI entry point
├── commands/
│ ├── branch.ts # Branch management
│ ├── pr.ts # Pull request creation
│ ├── sync.ts # Repository synchronization
│ └── status.ts # Enhanced status display
├── bunli.config.ts # Configuration
├── package.json # Dependencies
└── README.md # Documentation
Commands
branch - Branch Management
Create, switch, and delete branches with safety checks.
Source: commands/branch.ts
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'
export default defineCommand({
name: 'branch' as const,
description: 'Create, switch, or manage branches',
alias: 'br', // Short alias
options: {
name: option(
z.string()
.min(1, 'Branch name cannot be empty')
.regex(/^[a-zA-Z0-9._/-]+$/, 'Branch name can only contain letters, numbers, dots, underscores, hyphens, and slashes')
.refine((value) => !value.startsWith('/') && !value.endsWith('/'), 'Branch name cannot start or end with "/"')
.refine((value) => !value.includes('..'), 'Branch name cannot include ".."'),
{
short: 'n',
description: 'Branch name'
}
),
base: option(
z.string().default('main'),
{
short: 'b',
description: 'Base branch to create from'
}
),
switch: option(
z.coerce.boolean().default(false),
{
short: 's',
description: 'Switch to the branch after creating'
}
),
delete: option(
z.coerce.boolean().default(false),
{
short: 'd',
description: 'Delete the branch'
}
),
force: option(
z.coerce.boolean().default(false),
{
short: 'f',
description: 'Force the operation'
}
)
},
handler: async ({ flags, colors, spinner, shell }) => {
const spin = spinner('Working with branches...')
try {
if (flags.delete) {
// Delete branch
spin.update(`Deleting branch '${flags.name}'...`)
if (!flags.force) {
// Check if branch exists
const { stdout: branches } = await shell`git branch --list ${flags.name}`
if (!branches.toString().trim()) {
throw new Error(`Branch '${flags.name}' does not exist`)
}
// Check if it's the current branch
const { stdout: currentBranch } = await shell`git branch --show-current`
if (currentBranch.toString().trim() === flags.name) {
throw new Error(`Cannot delete current branch '${flags.name}'`)
}
}
await shell`git branch ${flags.force ? '-D' : '-d'} ${flags.name}`
spin.succeed(`Deleted branch '${flags.name}'`)
} else {
// Create branch
await shell`git checkout -b ${flags.name} ${flags.base}`
spin.succeed(`Created branch '${flags.name}' from '${flags.base}'`)
}
// Show branch status
const { stdout: currentBranch } = await shell`git branch --show-current`
console.log(`Current: ${colors.cyan(currentBranch.toString().trim())}`)
} catch (error) {
spin.fail('Branch operation failed')
console.error(colors.red(`Error: ${error.message}`))
}
}
})
# Create new branch
bun cli.ts branch --name feature/auth --base main
# Create and switch
bun cli.ts branch -n feature/auth -s
# Delete branch
bun cli.ts branch -n old-feature --delete
# Force delete
bun cli.ts branch -n old-feature -d --force
# Using alias
bun cli.ts br -n feature/test -s
status - Enhanced Git Status
Comprehensive repository status with detailed information.
Source: commands/status.ts
export default defineCommand({
name: 'status' as const,
description: 'Enhanced git status with detailed information',
alias: 'st',
options: {
detailed: option(
z.coerce.boolean().default(false),
{ short: 'd', description: 'Show detailed status information' }
),
branches: option(
z.coerce.boolean().default(false),
{ short: 'b', description: 'Show branch information' }
),
remote: option(
z.coerce.boolean().default(false),
{ short: 'r', description: 'Show remote information' }
),
history: option(
z.coerce.number()
.int('History count must be a whole number')
.min(1, 'History count must be at least 1')
.max(50, 'History count cannot exceed 50')
.optional(),
{ short: 'h', description: 'Show commit history (1-50 commits)' }
)
},
handler: async ({ flags, colors, shell }) => {
const { stdout: status } = await shell`git status --porcelain`
const { stdout: currentBranch } = await shell`git branch --show-current`
console.log(colors.bold('Git status'))
console.log(`Branch: ${colors.cyan(currentBranch.toString().trim())}`)
// Show remote tracking
if (flags.remote || flags.detailed) {
const { stdout: behind } = await shell`git rev-list --count HEAD..@{u} 2>/dev/null || echo "0"`
const { stdout: ahead } = await shell`git rev-list --count @{u}..HEAD 2>/dev/null || echo "0"`
const behindCount = parseInt(behind.toString().trim())
const aheadCount = parseInt(ahead.toString().trim())
if (behindCount > 0) {
console.log(`Behind: ${colors.red(String(behindCount))} commits`)
}
if (aheadCount > 0) {
console.log(`Ahead: ${colors.green(String(aheadCount))} commits`)
}
}
// Show working directory status
if (status.toString().trim()) {
const lines = status.toString().trim().split('\n')
const staged = lines.filter((line: string) => line.startsWith('A ') || line.startsWith('M '))
const modified = lines.filter((line: string) => line.startsWith(' M'))
console.log(`Staged: ${colors.green(String(staged.length))} files`)
console.log(`Modified: ${colors.yellow(String(modified.length))} files`)
}
}
})
# Basic status
bun cli.ts status
# Detailed status
bun cli.ts status --detailed
# Show remote info
bun cli.ts status --remote
# Show commit history
bun cli.ts status --history 10
sync - Repository Synchronization
Sync with remote repository, handling conflicts and stashing.
Source: commands/sync.ts
export default defineCommand({
name: 'sync' as const,
description: 'Sync with upstream repository',
alias: 'pull',
options: {
remote: option(
z.string().default('origin'),
{ short: 'r', description: 'Remote name to sync with' }
),
branch: option(
z.string().optional(),
{ short: 'b', description: 'Branch to sync (defaults to current branch)' }
),
rebase: option(
z.coerce.boolean().default(false),
{ description: 'Use rebase instead of merge' }
),
prune: option(
z.coerce.boolean().default(false),
{ short: 'p', description: 'Remove remote-tracking branches that no longer exist' }
)
},
handler: async ({ flags, colors, spinner, shell, prompt }) => {
const spin = spinner('Syncing with remote...')
let stashedChanges = false
try {
// Get current branch
const currentBranch = flags.branch ||
(await shell`git branch --show-current`).stdout.toString().trim()
// Check for uncommitted changes
const { stdout: status } = await shell`git status --porcelain`
if (status.toString().trim()) {
const stash = await prompt.confirm(
'You have uncommitted changes. Stash them?',
{ default: true }
)
if (stash) {
await shell`git stash push -m "Auto-stash before sync"`
stashedChanges = true
console.log(colors.yellow('Changes stashed'))
}
}
// Fetch latest changes
spin.update('Fetching latest changes...')
await shell`git fetch ${flags.remote}`
if (flags.prune) {
await shell`git remote prune ${flags.remote}`
}
// Pull changes
if (flags.rebase) {
await shell`git pull --rebase ${flags.remote} ${currentBranch}`
console.log(colors.green('Rebased successfully'))
} else {
await shell`git pull ${flags.remote} ${currentBranch}`
console.log(colors.green('Merged successfully'))
}
// Restore stashed changes
if (stashedChanges) {
const restore = await prompt.confirm('Restore stashed changes?', { default: true })
if (restore) {
await shell`git stash pop`
console.log(colors.green('Stashed changes restored'))
}
}
spin.succeed('Sync completed successfully')
} catch (error) {
spin.fail('Sync failed')
console.error(colors.red(`Error: ${error.message}`))
}
}
})
# Basic sync
bun cli.ts sync
# Sync with rebase
bun cli.ts sync --rebase
# Sync and prune
bun cli.ts sync --prune
# Sync specific branch
bun cli.ts sync --branch develop
pr - Pull Request Creation
Create pull requests with GitHub CLI integration.
Usage:
# Create PR
bun cli.ts pr --title "Add new feature"
# Draft PR
bun cli.ts pr -t "WIP: Feature" --draft
# With reviewers
bun cli.ts pr -t "Fix bug" -r "alice,bob"
Configuration
Source:bunli.config.ts
import { defineConfig } from '@bunli/core'
export default defineConfig({
name: 'git-tool',
version: '1.0.0',
description: 'Git workflow automation CLI',
plugins: [],
commands: {
entry: './cli.ts',
directory: './commands'
},
build: {
entry: './cli.ts',
outdir: './dist',
targets: ['darwin-arm64', 'darwin-x64'], // Multi-platform
compress: true,
minify: true,
sourcemap: false
}
})
CLI Entry Point
Source:cli.ts
#!/usr/bin/env bun
import { createCLI } from '@bunli/core'
import branchCommand from './commands/branch.js'
import prCommand from './commands/pr.js'
import statusCommand from './commands/status.js'
import syncCommand from './commands/sync.js'
const cli = await createCLI()
cli.command(branchCommand)
cli.command(prCommand)
cli.command(syncCommand)
cli.command(statusCommand)
await cli.run()
Key Patterns
1. Shell Command Execution
Use theshell context to run Git commands:
handler: async ({ shell }) => {
// Execute Git commands
const { stdout } = await shell`git branch --show-current`
const branchName = stdout.toString().trim()
}
2. Command Aliases
Provide short aliases for frequently used commands:defineCommand({
name: 'branch',
alias: 'br', // Use as: git-tool br
// ...
})
3. Interactive Prompts
Ask for user confirmation before destructive operations:handler: async ({ prompt }) => {
const confirmed = await prompt.confirm(
'Delete this branch?',
{ default: false }
)
if (confirmed) {
// Proceed with deletion
}
}
4. Safety Checks
Validate conditions before executing commands:if (!flags.force) {
// Check if branch exists
const { stdout } = await shell`git branch --list ${flags.name}`
if (!stdout.toString().trim()) {
throw new Error(`Branch '${flags.name}' does not exist`)
}
}
5. Regex Validation
Use Zod regex validation for branch names:name: option(
z.string()
.regex(/^[a-zA-Z0-9._/-]+$/, 'Invalid characters in branch name')
.refine(val => !val.includes('..'), 'Cannot include ".."')
)
What You Learned
- ✅ External tool integration (Git)
- ✅ Shell command execution with
shell - ✅ Command aliases
- ✅ Interactive prompts and confirmations
- ✅ Regex validation with Zod
- ✅ Error handling for shell commands
- ✅ Safety checks for destructive operations
- ✅ Multi-platform builds
Next Steps
Explore more advanced patterns:- Task Runner - Complex validation and wizards
- Dev Server - Plugin system and long-running processes
- Hello World - Basic command structure