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:
- Runs tests
- Runs linter
- Stages all changes
- Prompts for commit message (using conventional commits)
- 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:
cd user- Jump to~/code/svc-userrun dev- Start dev server (defined in task runner)ports- Check which ports are freegit-smart-commit- Commit changes with tests/lintcd payment- Switch to microserviceenv prod- Load production environment variables
Everything is fast, keyboard-driven, and composable.
Lessons Learned
- Start simple - Shell scripts first, refactor to Go if needed
- Document everything - Each tool has
--help - Version control - All scripts in git
- Keep it small - Tools that do one thing well
- 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.