Skip to main content

gotools: initial commit with `gobench`

ID
8f2351d
date
2026-11-01 17:44:35+00:00
author
Alex Chan <alex@alexwlchan.net>
message
gotools: initial commit with `gobench`
changed files
10 files, 542 additions

Changed files

.gitignore (0 → 6)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f47cb20
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+*.out

CHANGELOG.md (0 → 69)

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3ab62f8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## v1.0.0 - 2026-11-01
+
+Initial release with `gobench`.

LICENSE (0 → 1053)

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e732212
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Alex Chan
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.

README.md (0 → 1633)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..28b7fa4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,59 @@
+# gotools
+
+gotools is a collection of command-line utilities for working with Go.
+
+## gobench
+
+`gobench` automated running Go benchmarks across your current working tree and a base Git branch (usually `main`).
+It alternates benchmark runs to minimise system noise, and pipes the output into `benchstat` for comparison.
+This allows you to see the impact of your unmerged changes.
+
+### Installation
+
+`gobench` requires `benchstat` to be installed:
+
+```console
+$ go install golang.org/x/perf/cmd/benchstat@latest
+```
+
+Then install `gobench`:
+
+```console
+$ go install alexwlchan.net/projects/gotools/cmd/gobench@latest
+```
+
+### Usage and flags
+
+Run `gobench` and pass arguments you'd pass to `go test` to choose which benchmark to run.
+For example:
+
+```console
+$ gobench ./control/controlapi -bench=BenchmarkDevices
+```
+
+There are optional flags to control the `gobench` behaviour:
+
+*   `-base` (default `main`) -- set the base Git branch or commit to compare against
+*   `-samples` (default `6`) -- the numebr of alternating sample rounds to run
+*   `-no-sync` (default `false`) -- skip copying dirty/untracked working tree files to the base worktree
+
+### Demo: measuring an optimisation
+
+1.  Check out this repo at `main`.
+2.  Create a new feature branch:
+
+    ```console
+    $ git checkout -b fib-faster
+    ```
+
+3.  In `example/fib/fib_test.go`, replace `FibRecursive(20)` with `FibIterative(20)`.
+4.  Commit your changes.
+5.  Run `gobench` to measure the performance impact:
+
+    ```console
+    $ go run ./cmd/gobench ./example/fib -bench=BenchmarkFib
+    ```
+
+## License
+
+This project is shared under the [MIT license](./LICENSE).

cmd/gobench/gobench.go (0 → 6745)

diff --git a/cmd/gobench/gobench.go b/cmd/gobench/gobench.go
new file mode 100644
index 0000000..2c7216d
--- /dev/null
+++ b/cmd/gobench/gobench.go
@@ -0,0 +1,248 @@
+package main
+
+// gobench is a tool for running benchmarks of Go code and comparing
+// changes between a branch and a base (usually main).
+
+import (
+	"bytes"
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"alexwlchan.net/projects/gotools/internal/git"
+	"github.com/schollz/progressbar/v3"
+)
+
+var (
+	baseFlag    = flag.String("base", "main", "base git branch to compare against")
+	samplesFlag = flag.Int("samples", 6, "number of alternating samples")
+	noSyncFlag  = flag.Bool("no-sync", false, "do not copy dirty/untracked files to the base worktree")
+)
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	// Parse command-line arguments.
+	goArgs, err := parseArgs(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("parsing arguments: %w", err)
+	}
+
+	if len(goArgs) == 0 {
+		return errors.New("missing arguments for `go test`")
+	}
+
+	// Check that benchstat is installed
+	if _, err := exec.LookPath("benchstat"); err != nil {
+		return fmt.Errorf("benchstat is required: install with `go install golang.org/x/perf/cmd/benchstat@latest`")
+	}
+
+	// Prepare two copies of the Git repo: one on the branch, one on main.
+	pair, err := prepareGitRepos(*baseFlag, !*noSyncFlag)
+	if err != nil {
+		return err
+	}
+	defer pair.Cleanup()
+
+	branchName, _ := pair.Branch.CurrentBranch()
+
+	// Run a series of alternating benchmarks.
+	fmt.Printf("Running %d alternating samples...\n", *samplesFlag)
+	bar := progressbar.Default(int64(*samplesFlag * 2))
+
+	var baseBuf, branchBuf bytes.Buffer
+
+	for i := 1; i <= *samplesFlag; i++ {
+		// Run on base
+		baseOutput, err := runSingleBenchmark(pair.Base, goArgs)
+		if err != nil {
+			return fmt.Errorf("benchmark failed on %s:\n%s", *baseFlag, baseOutput)
+		}
+		baseBuf.Write(baseOutput)
+		bar.Add(1)
+
+		// Run on branch
+		branchOutput, err := runSingleBenchmark(pair.Branch, goArgs)
+		if err != nil {
+			return fmt.Errorf("benchmark failed on %s:\n%s", branchName, branchOutput)
+		}
+		branchBuf.Write(branchOutput)
+		bar.Add(1)
+	}
+
+	// Dump benchmark output into temporary text files.
+	tmpDir, err := os.MkdirTemp("", "gobench-results-*")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tmpDir)
+
+	baseFile := filepath.Join(tmpDir, "base.txt")
+	branchFile := filepath.Join(tmpDir, "branch.txt")
+
+	if err := os.WriteFile(baseFile, baseBuf.Bytes(), 0644); err != nil {
+		return fmt.Errorf("writing base results: %w", err)
+	}
+	if err := os.WriteFile(branchFile, branchBuf.Bytes(), 0644); err != nil {
+		return fmt.Errorf("writing branch results: %w", err)
+	}
+
+	// Compare the benchmark results using benchstat.
+	baseCommit, _ := pair.Base.RevParse("HEAD")
+	branchCommit, _ := pair.Branch.RevParse("HEAD")
+
+	baseLabel := fmt.Sprintf("%s (%s)", *baseFlag, baseCommit)
+	branchLabel := fmt.Sprintf("%s (%s)", branchName, branchCommit)
+
+	fmt.Println("\n== Benchmark results ==")
+	benchstatBin := "benchstat"
+	statCmd := exec.Command(
+		benchstatBin,
+		fmt.Sprintf("%s=%s", baseLabel, baseFile),
+		fmt.Sprintf("%s=%s", branchLabel, branchFile),
+	)
+	statCmd.Stdout = os.Stdout
+	statCmd.Stderr = os.Stderr
+	return statCmd.Run()
+}
+
+// runSingleBenchmark runs a single instance of a benchmark in a repo,
+// and returns the output.
+func runSingleBenchmark(repo *git.Repo, goArgs []string) (output []byte, err error) {
+	// Resolve Go binary: prefer repo-level ./tool/go if present, fallback to PATH
+	goBin := "go"
+	toolGo := filepath.Join(repo.Root(), "tool", "go")
+	if fi, err := os.Stat(toolGo); err == nil && (fi.Mode()&0111 != 0) {
+		goBin = toolGo
+	}
+
+	cmdBase := append([]string{"test", "-count=1", "-run=^$"}, goArgs...)
+
+	cmd := exec.Command(goBin, cmdBase...)
+	cmd.Dir = repo.Root()
+	var buf bytes.Buffer
+	cmd.Stdout = &buf
+	cmd.Stderr = &buf
+	err = cmd.Run()
+	return buf.Bytes(), err
+}
+
+// RepoPair holds the two checkouts needed for benchmark comparisons.
+type RepoPair struct {
+	Branch  *git.Repo
+	Base    *git.Repo
+	Cleanup func()
+}
+
+// prepareGitRepos prepares two checkouts of the current Git repo:
+//
+//  1. The current working directory, which is left as-is, and
+//  2. A temporary Git worktree with the base branch and any uncommitted
+//     changes from the current directory
+func prepareGitRepos(baseRef string, syncDirty bool) (*RepoPair, error) {
+	branch, err := git.Current()
+	if err != nil {
+		return nil, err
+	}
+
+	currentBranch, err := branch.CurrentBranch()
+	if err != nil {
+		return nil, err
+	}
+
+	baseCommit, err := branch.RevParse(baseRef)
+	if err != nil {
+		return nil, fmt.Errorf("could not resolve base reference %q: %w", baseRef, err)
+	}
+
+	headCommit, err := branch.RevParse("HEAD")
+	if err != nil {
+		return nil, err
+	}
+
+	isDirty, err := branch.IsDirty()
+	if err != nil {
+		return nil, err
+	}
+
+	if currentBranch == baseRef {
+		return nil, fmt.Errorf("cannot run directly on base branch %q", baseRef)
+	}
+	if baseCommit == headCommit && !isDirty {
+		return nil, fmt.Errorf("HEAD is identical to %q (%s) and working tree is clean", baseRef, headCommit)
+	}
+
+	// Create temp directory for base worktree
+	tmpDir, err := os.MkdirTemp("", "gobench-worktree-*")
+	if err != nil {
+		return nil, err
+	}
+
+	worktreeDir := filepath.Join(tmpDir, "base")
+	fmt.Printf("Setting up temporary worktree for %s (%s)...\n", baseRef, baseCommit)
+
+	cleanupWorktree, err := branch.AddWorktree(worktreeDir, baseCommit)
+	if err != nil {
+		_ = os.RemoveAll(tmpDir)
+		return nil, err
+	}
+
+	cleanup := func() {
+		cleanupWorktree()
+		_ = os.RemoveAll(tmpDir)
+	}
+
+	if isDirty && syncDirty {
+		fmt.Printf("Syncing modified/untracked files into %s worktree...\n", baseRef)
+		if err := branch.SyncChangesTo(worktreeDir); err != nil {
+			cleanup()
+			return nil, fmt.Errorf("failed to sync files to base worktree: %w", err)
+		}
+	}
+
+	return &RepoPair{
+		Branch:  branch,
+		Base:    git.Open(worktreeDir),
+		Cleanup: cleanup,
+	}, nil
+}
+
+// parseArgs separates arguments for `go bench` and arguments to be forwarded
+// to `go test`.
+func parseArgs(args []string) (goArgs []string, err error) {
+	var gobenchArgs []string
+
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+		switch {
+		case arg == "-no-sync" || arg == "--no-sync":
+			gobenchArgs = append(gobenchArgs, arg)
+		case arg == "-base" || arg == "--base" || arg == "-samples" || arg == "--samples":
+			gobenchArgs = append(gobenchArgs, arg)
+			if i+1 < len(args) {
+				i++
+				gobenchArgs = append(gobenchArgs, args[i])
+			}
+		case strings.HasPrefix(arg, "-base=") || strings.HasPrefix(arg, "--base=") ||
+			strings.HasPrefix(arg, "-samples=") || strings.HasPrefix(arg, "--samples="):
+			gobenchArgs = append(gobenchArgs, arg)
+		default:
+			goArgs = append(goArgs, arg)
+		}
+	}
+
+	if err := flag.CommandLine.Parse(gobenchArgs); err != nil {
+		return nil, err
+	}
+
+	return goArgs, nil
+}

example/fib/fib.go (0 → 392)

diff --git a/example/fib/fib.go b/example/fib/fib.go
new file mode 100644
index 0000000..47602d6
--- /dev/null
+++ b/example/fib/fib.go
@@ -0,0 +1,21 @@
+package fib
+
+// FibRecursive computes the nth Fibonacci number recursively (O(2^n)).
+func FibRecursive(n int) int {
+	if n <= 1 {
+		return n
+	}
+	return FibRecursive(n-1) + FibRecursive(n-2)
+}
+
+// FibIterative computes the nth Fibonacci number iteratively (O(n)).
+func FibIterative(n int) int {
+	if n <= 1 {
+		return n
+	}
+	a, b := 0, 1
+	for i := 2; i <= n; i++ {
+		a, b = b, a+b
+	}
+	return b
+}

example/fib/fib_test.go (0 → 184)

diff --git a/example/fib/fib_test.go b/example/fib/fib_test.go
new file mode 100644
index 0000000..3642147
--- /dev/null
+++ b/example/fib/fib_test.go
@@ -0,0 +1,12 @@
+package fib
+
+import (
+	"testing"
+)
+
+func BenchmarkFib(b *testing.B) {
+	for b.Loop() {
+		// On a branch, change this to FibIterative to see the improvement.
+		_ = FibRecursive(20)
+	}
+}

go.mod (0 → 319)

diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2107315
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module alexwlchan.net/projects/gotools
+
+go 1.27.0
+
+require (
+	github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/schollz/progressbar/v3 v3.19.1 // indirect
+	golang.org/x/sys v0.49.0 // indirect
+	golang.org/x/term v0.47.0 // indirect
+)

go.sum (0 → 897)

diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d01825d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
+github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
+golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=

internal/git/git.go (0 → 3826)

diff --git a/internal/git/git.go b/internal/git/git.go
new file mode 100644
index 0000000..f25495e
--- /dev/null
+++ b/internal/git/git.go
@@ -0,0 +1,156 @@
+package git
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+// Repo represents a Git repository working tree.
+type Repo struct {
+	root string
+}
+
+// Open creates a new Repo rooted at the given directory.
+func Open(root string) *Repo {
+	return &Repo{root: root}
+}
+
+// Current discovers and returns the Repo containing the current working directory.
+func Current() (*Repo, error) {
+	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("not in a git repository: %w", err)
+	}
+	return &Repo{root: strings.TrimSpace(string(out))}, nil
+}
+
+// Root returns the top-level path of the repository.
+func (r *Repo) Root() string {
+	return r.root
+}
+
+// Run executes a Git command in the root of this Repo.
+func (r *Repo) Run(args ...string) (string, error) {
+	cmd := exec.Command("git", args...)
+	cmd.Dir = r.root
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("git %s failed: %w\n%s", strings.Join(args, " "), err, string(out))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// CurrentBranch returns the name of the checked-out branch, or the
+// short commit hash if detached.
+func (r *Repo) CurrentBranch() (string, error) {
+	branch, err := r.Run("symbolic-ref", "--short", "-q", "HEAD")
+	if err == nil {
+		return branch, nil
+	}
+	return r.RevParse("HEAD")
+}
+
+// RevParse resolves a ref (for example, "HEAD", "main") to a 7-character short commit hash.
+func (r *Repo) RevParse(ref string) (string, error) {
+	return r.Run("rev-parse", "--short", ref)
+}
+
+// IsDirty returns true if the working tree has uncommitted staged, unstaged, or untracked changes.
+func (r *Repo) IsDirty() (bool, error) {
+	out, err := r.Run("status", "--porcelain")
+	if err != nil {
+		return false, err
+	}
+	return len(out) > 0, nil
+}
+
+// ChangedFiles returns a deduplicated list of relative paths for all modified, staged, and untracked files.
+func (r *Repo) ChangedFiles() ([]string, error) {
+	diffOut, err := r.Run("diff", "--name-only", "HEAD", "--")
+	if err != nil {
+		return nil, err
+	}
+	untrackedOut, err := r.Run("ls-files", "--others", "--exclude-standard")
+	if err != nil {
+		return nil, err
+	}
+
+	seen := make(map[string]bool)
+	var files []string
+	for f := range strings.SplitSeq(diffOut+"\n"+untrackedOut, "\n") {
+		f = strings.TrimSpace(f)
+		if f == "" || seen[f] {
+			continue
+		}
+		seen[f] = true
+		files = append(files, f)
+	}
+	return files, nil
+}
+
+// AddWorktree creates a detached worktree at dest pointing to ref.
+// It returns a cleanup function that forcibly removes the worktree.
+func (r *Repo) AddWorktree(dest, ref string) (cleanup func(), err error) {
+	if _, err := r.Run("worktree", "add", "--detach", "-q", dest, ref); err != nil {
+		return nil, err
+	}
+	cleanup = func() {
+		_, _ = r.Run("worktree", "remove", "--force", dest)
+	}
+	return cleanup, nil
+}
+
+// SyncChangesTo copies all uncommitted and untracked files to a target directory,
+// removing destination files that were deleted locally.
+func (r *Repo) SyncChangesTo(destDir string) error {
+	files, err := r.ChangedFiles()
+	if err != nil {
+		return err
+	}
+
+	for _, relPath := range files {
+		src := filepath.Join(r.root, relPath)
+		dst := filepath.Join(destDir, relPath)
+
+		fi, err := os.Stat(src)
+		if os.IsNotExist(err) {
+			_ = os.Remove(dst)
+			continue
+		}
+		if err != nil || fi.IsDir() {
+			continue
+		}
+
+		if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
+			return err
+		}
+		if err := copyFile(src, dst); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func copyFile(src, dst string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	if _, err := io.Copy(out, in); err != nil {
+		return err
+	}
+	return out.Sync()
+}