Skip to main content

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}`))
    }
  }
})
Usage:
# 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`)
    }
  }
})
Usage:
# 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}`))
    }
  }
})
Usage:
# 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 the shell 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:

Source Code

View the complete source code on GitHub: examples/git-tool

Build docs developers (and LLMs) love