We developers spend a surprising amount of time on repetitive tasks: starting services, running migrations, clearing caches, switching contexts, running test suites. These small tasks add up. Here’s how I’ve automated my workflow using Go and shell scripts.

The Philosophy

Automation should be:

  • Fast - Must be faster than doing it manually
  • Simple - One or two commands at most
  • Composable - Small tools that work together
  • Version controlled - Scripts in git, not scattered in ~/bin

Shell Scripts: The Glue

Shell scripts are perfect for orchestrating commands. My ~/bin is a git repo, not a dumping ground.

Project Boilerplate

Creating a new Go project with all the tooling I use (golangci-lint, pre-commit hooks, Makefile) was tedious. Now:

#!/bin/bash
# ~/bin/new-go-project

if [ -z "$1" ]; then
  echo "Usage: new-go-project <name>"
  exit 1
fi

mkdir -p "$1"
cd "$1" || exit 1

# Initialize Go module
go mod init "$1"

# Create directory structure
mkdir -p cmd/server internal/handlers internal/models pkg/utils

# Create Makefile
cat > Makefile <<'EOF'
run:
  go run ./cmd/server

test:
  go test -v ./...

lint:
  golangci-lint run

build:
  go build -o bin/server ./cmd/server
EOF

# Create .gitignore
cat > .gitignore <<'EOF'
bin/
*.test
*.out
coverage.txt
EOF

echo "✓ Project $1 created"

Now new-go-project myservice sets up everything in seconds.

Git Workflow Automation

I have a git-smart-commit script that:

  1. Runs tests
  2. Runs linter
  3. Stages all changes
  4. Prompts for commit message (using conventional commits)
  5. Creates commit
#!/bin/bash
set -e

echo "Running tests..."
go test ./... || exit 1

echo "Running linter..."
golangci-lint run || exit 1

echo "Staging changes..."
git add .

echo "Enter commit message (conventional commits):"
read -r msg

git commit -m "$msg"

Go Tools: The Heavy Lifters

When scripts get complex or need cross-platform support, I reach for Go.

Port Manager

I often run multiple services locally. Remembering which ports are in use is annoying. I wrote a Go tool:

// ~/bin/ports
package main

import (
  "fmt"
  "net"
  "os"
  "sort"
)

func main() {
  listeners := []struct {
    port int
    proc string
  }{}

  // Check common ports
  for _, port := range []int{3000, 5000, 8000, 8080} {
    ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
    if err != nil {
      listeners = append(listeners, struct{port int; proc string}{port, "in use"})
    } else {
      ln.Close()
      listeners = append(listeners, struct{port int; proc string}{port, "free"})
    }
  }

  // Print table
  fmt.Println("Port status:")
  for _, l := range listeners {
    fmt.Printf("  %d: %s\n", l.port, l.proc)
  }
}

Running ports shows:

Port status:
  3000: in use
  5000: free
  8000: in use
  8080: free

Workspace Switcher

I work in ~/code with many projects. Switching directories is tedious:

#!/bin/bash
# ~/bin/cd
# Wrapper around cd with fuzzy search

if [ -z "$1" ]; then
  # No arg: use fzf to search ~/code
  dir=$(find ~/code -maxdepth 2 -type d | fzf)
  if [ -n "$dir" ]; then
    cd "$dir" || exit 1
    echo "Switched to: $dir"
  fi
else
  # Arg provided: exact match or partial search
  dir=$(find ~/code -maxdepth 2 -type d -iname "*$1*" | head -1)
  if [ -n "$dir" ]; then
    cd "$dir" || exit 1
    echo "Switched to: $dir"
  else
    echo "No match for: $1"
    exit 1
  fi
fi

Used as a shell function in .bashrc:

cd() {
  if [ $# -eq 0 ]; then
    builtin cd ~/bin/$(hostname) || return
  elif [ -d "$1" ]; then
    builtin cd "$1" || return
  else
    builtin cd ~/bin/ "$@" || return
  fi
}

Task Runner

I used make for task automation, but Makefiles get messy. I wrote a simple Go-based task runner called dstask (inspired by the existing dstask, but simpler):

// ~/bin/run
package main

type Task struct {
  Name   string
  Cmd    string
  Desc   string
}

var tasks = []Task{
  {"dev", "go run ./cmd/server", "Start dev server"},
  {"test", "go test -v ./...", "Run tests"},
  {"build", "go build -o bin/server ./cmd/server", "Build"},
  {"lint", "golangci-lint run", "Run linter"},
  {"clean", "rm -rf bin/", "Clean build artifacts"},
}

func main() {
  if len(os.Args) < 2 {
    printTasks()
    return
  }

  taskName := os.Args[1]
  for _, task := range tasks {
    if task.Name == taskName {
      cmd := exec.Command("sh", "-c", task.Cmd)
      cmd.Stdout = os.Stdout
      cmd.Stderr = os.Stderr
      cmd.Run()
      return
    }
  }

  fmt.Printf("Unknown task: %s\n", taskName)
  printTasks()
}

Now run dev, run test, etc. handles project tasks cleanly.

Environment Management

I have a Go tool for switching between projects with different environment variables:

# ~/bin/env
# Usage: env <project>

ENV_FILE="$HOME/.envs/$1.sh"

if [ -f "$ENV_FILE" ]; then
  source "$ENV_FILE"
  echo "Loaded environment: $1"
else
  echo "No environment found for: $1"
  exit 1
fi

Projects have their own .envs/project.sh:

# ~/.envs/svc-user.sh
export DB_HOST=localhost
export DB_PORT=5432
export AWS_PROFILE=dev
export KUBECONTEXT=minikube

Putting It Together

My typical workflow:

  1. cd user - Jump to ~/code/svc-user
  2. run dev - Start dev server (defined in task runner)
  3. ports - Check which ports are free
  4. git-smart-commit - Commit changes with tests/lint
  5. cd payment - Switch to microservice
  6. env prod - Load production environment variables

Everything is fast, keyboard-driven, and composable.

Lessons Learned

  1. Start simple - Shell scripts first, refactor to Go if needed
  2. Document everything - Each tool has --help
  3. Version control - All scripts in git
  4. Keep it small - Tools that do one thing well
  5. Use standard tools - fzf, ripgrep, jq are your friends

What’s Next?

I’m experimenting with:

  • TUI for task management (bubbletea-based)
  • Integrating AI for commit message generation
  • Cross-platform workspace manager

The goal is frictionless development. Every second saved on repetitive tasks is a second gained for actual coding.

Resources