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
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()
+}