Using Go instead of bash for scripts
in: Go programming articles
I like to automate my programming work.
In every programming project I ended up writing bash (on Unix and Mac) and batch / PowerShell (on Windows) scripts.
I settled on a convention to put scripts in a directory s (it’s short, fast to type and a shortcut for scripts).
I had ./s/run.sh to run the program locally../s/tests.sh to run tests. ./s/deploy.sh to deploy web apps to the server etc.
It worked but I wasn’t quite happy.
For cross-platform projects I had to write the same script twice (./s/run.sh and ./s/run.bat).
I write those scripts so infrequently that I every time I need to re-learn basics. How do I declare a function? How do I write if? How do I write a loop?
In bash, simple things are complicated and non-simple things are very complicated.
This article describes how I replaced bash scripts with a single, multiple-purpose Go program.
You can see a full example at https://github.com/kjk/notionapi/tree/master/do

Replacing bash with Go

One day it hit me: I would rather write the helper scripts in Go.
Go is cross-platform; I don’t have to write the same thing twice.
I write in Go daily so I can implement simple things quickly.
In Go simple things are simple and complicated things are possible.
The one drawback is more lines of code but the difference is immaterial. Those are short programs either way.
This article describes a system I refined by using it in multiple projects.

Establishing conventions

It’s all about convenience so less typing is better.
Establishing conventions to share between multiple projects frees mental energy for more important things.
The system I settled on is:

cd do
go run -race . $@
@cd do
@go run -race . %*
function doit() {
	if [ -f ./do.sh ]; then
		./do.sh $@
	elif [ -f ./do/do.sh ]; then
		./do/do.sh $@
		echo "no do.sh or do/do.sh found"
I can then type `doit ${args}` to launch either `./do.sh` or `./do/do.sh` (whichever exists). 
In every project I can type doit -run which executes ./do/do.sh -run which executes cd do; go run . -run.
In the old system, that would be ./s/run.sh or .\s\run.bat.
Other cmd-line arguments trigger other actions e.g. doit -test, doit -deploy etc.
If I forget which flags are available, do without arguments prints them all.

A structure of do program

Main function checks cmd-line arguments and calls the right function to perform a given command.
Here’s an implementation of dispatching two commands: -run and -deploy.
func main() {
	fmt.Printf("topDir: '%s'\n", topDir())

	var (
		flgRun    bool
		flgDeploy bool
		flag.BoolVar(&flgRun, "run", false, "runs the program")
		flag.BoolVar(&flgDeploy, "deploy", false, "deploys to production")

	if flgRun {

	if flgDeploy {

  // this prints available flags

Running from a known current directory

When we run the program, we’re inside do directory
It’s important to know what is the current director so that when we refer to files in the project, we know their path.
By convention I set current directory to be top directory of the project.
The first thing that the program does is call cdToTopDir() which fixes the current directory to this known location.
The simplest implementation:
func cdToTopDir() {
	err := os.Chdir("..")
This relies on knowledge that we execute the program with cd do; go run . ${args}.
I also print the absolute path of current directory at the beginning to make sure it is correct.

Crashing on errors is fine

In a regular Go program, handling errors by propagating them to callers is key for writing robust software.
In short scripts it’s ok to panic when error happens. It makes for shorter code. panic prints the callstack which is handy when investigating unexpected errors.
I have a helper function must(err error) that panics if err is not nil:
func must(err error) {
	if err != nil {
		fmt.Printf("err: %s\n", err)
Here’s how to use it:
func readFile(path string) []byte {
	d, err := ioutil.ReadFile(path)
	return d

Executing programs

A common thing to do is executing other programs. For example, do -run would typically execute go build . -o myapp and ./myapp.
Go has an excellent os/exec package for that:
cmd := exec.Command("go", "build", ".", "-o", "myapp")
err := cmd.Run()

cmd = exec.Command("./myapp")
err = cmd.Run()
Other useful things possible with exec.Cmd:
cmd := exec.Command("./myapp")
cmd.Dir = "working/directory"
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS=linux", "GOARCH=amd64")
cmd := exec.Command("ls", "-lah")
// CombinedOutput() calls Run() and returns captured stdout / stderr as []byte
out, err := cmd.CombinedOutput()
fmt.Printf("output of ls:\n%s\n", string(out))
func openNotepadWithFile(path string) {
	cmd := exec.Command("notepad.exe", path)
	err := cmd.Start() // this starts the programs but doesn't wait for it to finish
func main() {
	// ...
	err := cmd.Start()

	// ensure to kill the process upon exit
	defer cmd.Process.Kill()
fmt.Printf("Running: %s\n", strings.Join(cmd.Args[1:], " "))
// set program's stdout / stderr ot console's stdout/stderr to
// see what it prints
// incompatible with capturing stdout / stderr with `CombinedOutput()`
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

Helper functions

Scripts in different projects often need the same functionality.
I keep common functions in a separate file util.go so that I can quickly bootstrap new project.
Here are a few common helper functions.


Inspired by C, panics if condition is not true. Use to verify you get expected results.
func assert(cond bool, format string, args ...interface{}) {
	if cond {
	s := fmt.Sprintf(format, args...)


To be used instead of fmt.Printf. The advantage is that if we want to e.g. start logging to file, we need to change just logf function.
// a centralized place allows us to tweak logging, if need be
func logf(format string, args ...interface{}) {
	if len(args) == 0 {
	fmt.Printf(format, args...)


When working on backends for web apps it’s convenient to auto-open the web site in the browser when starting the app locally.
// openBrowsers open web browser with a given url
// (can be http:// or file://)
func openBrowser(url string) {
	var err error
	switch runtime.GOOS {
	case "linux":
		err = exec.Command("xdg-open", url).Start()
	case "windows":
		err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
	case "darwin":
		err = exec.Command("open", url).Start()
		err = fmt.Errorf("unsupported platform")


Reads all files in a zip file and returns them as a map from file name to content.
func readZipFile(path string) map[string][]byte {
	r, err := zip.OpenReader(path)
	defer r.Close()
	res := map[string][]byte{}
	for _, f := range r.File {
		rc, err := f.Open()
		d, err := ioutil.ReadAll(rc)
		res[f.Name] = d
	return res

readFile, writeFile

Shorter way to read / write files.
func readFile(path string) []byte {
	d, err := ioutil.ReadFile(path)
	return d

func writeFile(path string, data []byte) {
	err := ioutil.WriteFile(path, data, 0666)


Returns a path of the user’s home directory.
func getHomeDir() string {
	s, err := os.UserHomeDir()
	return s


Equivalent of cp in bash.
func cpFile(dstPath, srcPath string) {
	d, err := ioutil.ReadFile(srcPath)
	err = ioutil.WriteFile(dstPath, d, 0666)


To prevent accidental deploys, my scripts use checkGitClean and refuse to deploy if there are un-commited changes to working directory:
var (
	verbose bool

func runCmd(cmd *exec.Cmd) string {
	if verbose {
		fmt.Printf("> %s\n", strings.Join(cmd.Args, " "))
	out, err := cmd.CombinedOutput()
	if err != nil {
		fmt.Printf("%s failed with '%s'. Output:\n%s\n", strings.Join(cmd.Args, " "), err, string(out))
	if verbose && len(out) > 0 {
		fmt.Printf("%s\n", out)
	return string(out)

func gitStatus(dir string) string {
	cmd := exec.Command("git", "status")
	if dir != "" {
		cmd.Dir = dir
	return runCmd(cmd)

func checkGitClean(dir string) {
	s := gitStatus(dir)
	expected := []string{
		"On branch master",
		"Your branch is up to date with 'origin/master'.",
		"nothing to commit, working tree clean",
	for _, exp := range expected {
		if !strings.Contains(s, exp) {
			fmt.Printf("Git repo in '%s' not clean.\nDidn't find '%s' in output of git status:\n%s\n", dir, exp, s)


This is a helper to create a .zip archive with the content of one or more directories or files.
Example use: createZipFile("archive.zip", ".", "myapp", "www")
This creates [archive.zip](http://archive.zip) with the content of myapp file and www directory. Those files are located in current (.) directory.
In fairness, would be shorter to sub-launch zip program, but I like the control.
func zipAddFile(zw *zip.Writer, zipName string, path string) {
	zipName = filepath.ToSlash(zipName)
	d, err := ioutil.ReadFile(path)
	w, err := zw.Create(zipName)
	_, err = w.Write(d)
	if verbose {
		fmt.Printf("  added %s from %s\n", zipName, path)

func zipDirRecur(zw *zip.Writer, baseDir string, dirToZip string) {
	dir := filepath.Join(baseDir, dirToZip)
	files, err := ioutil.ReadDir(dir)
	for _, fi := range files {
		if fi.IsDir() {
			zipDirRecur(zw, baseDir, filepath.Join(dirToZip, fi.Name()))
		} else if fi.Mode().IsRegular() {
			zipName := filepath.Join(dirToZip, fi.Name())
			path := filepath.Join(baseDir, zipName)
			zipAddFile(zw, zipName, path)
		} else {
			path := filepath.Join(baseDir, fi.Name())
			s := fmt.Sprintf("%s is not a dir or regular file", path)

func createZipFile(dst string, baseDir string, toZip ...string) {
	if len(toZip) == 0 {
		panic("must provide toZip args")
	if verbose {
		fmt.Printf("Creating zip file %s\n", dst)
	w, err := os.Create(dst)
	defer w.Close()
	zw := zip.NewWriter(w)
	for _, name := range toZip {
		path := filepath.Join(baseDir, name)
		fi, err := os.Stat(path)
		if fi.IsDir() {
			zipDirRecur(zw, baseDir, name)
		} else if fi.Mode().IsRegular() {
			zipAddFile(zw, name, path)
		} else {
			s := fmt.Sprintf("%s is not a dir or regular file", path)
	err = zw.Close()

wc -l

I like to know how big my programs are as measured by lines of code.
On Unix simple stats can be done with find . -name "*.go" | xargs wc -l
Similar functionality in Go is significantly larger. The good thing is that it’s cross-platform, more flexible and once written can be easily added to more projects.
Different projects want to count different files / directories so I built a flexible system that allows combining (with and and or) file filter functions.
I wrote a helper library https://github.com/kjk/u.
Here’s how I use it in a real project:
filter is a file filter function that tells us to count .go, .js, .html and .css files in all sub-directories but to exclude node_modules and tmpdata directories because they contain files not written by me:
package main

import (


var srcFiles = u.MakeAllowedFileFilterForExts(".go", ".js", ".html", ".css")
var excludeDirs = u.MakeExcludeDirsFilter("node_modules", "tmpdata")
var filter = u.MakeFilterAnd(srcFiles, excludeDirs)

func doLineCount() int {
	stats := u.NewLineStats()
	recursive := true
	err := stats.CalcInDir(".", filter, recursive)
	if err != nil {
		fmt.Printf("doLineCount: stats.wcInDir() failed with '%s'\n", err)
		return 1
	return 0

More Go resources

Written on Aug 16 2021. Topics: go.
Found a mistake, have a comment? Let me know.

Feedback about page:

Optional: your email if you want me to get back to you: