Hello, I'm Shrijith. I'm building git-lrc, an AI code reviewer that runs on every commit. It is free, unlimited, and source-available on Github. Star Us to help devs discover the project. Do give it a try and share your feedback for improving the product.
A while back, I was working on a Go project that needed to run multiple system commands in parallel.I thought, "No big deal, I’ll just fire up some goroutines and let them rip."
Big mistake—my outputsturned into a garbled mess, and I spent hours debugging.
That’s when I realized concurrent command execution needs careful handling.
Let’s walk through the problem and solve it with safecmd.go, a handy utility I built to make this easier for all of us.
The Problem
Running commands in parallel with goroutines sounds great, but it’s tricky.
Go’s bytes.Buffer isn’t thread-safe, so data gets corrupted.
Outputs to stdout and stderr can overlap across goroutines if not handled correctly.
It’s easy to mess up without proper synchronization.
Here’s an example of what goes wrong:
package main
import (
"bytes"
"fmt"
"os/exec"
"sync"
)
func main() {
var buf bytes.Buffer
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cmd := exec.Command("echo", fmt.Sprintf("Hello %d", id))
cmd.Stdout = &buf // Shared buffer, no protection
cmd.Run()
}(i)
}
wg.Wait()
fmt.Print(buf.String())
}
Output (varies, often broken):
Hello 0Hello 1Hello 2
The outputs smash together because the buffer isn’t safe for concurrent writes.
Here’s another messy case:
package main
import (
"fmt"
"os/exec"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cmd := exec.Command("echo", fmt.Sprintf("Thread %d", id))
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}(i)
}
wg.Wait()
}
Output (unpredictable):
Thread 2Thread 0Thread 1
No control over order, and it’s still risky with shared resources.
Step 1: Thread-Safe Buffers
Let’s fix the buffer issue first.
We’ll wrap bytes.Buffer with a mutex to make it safe.
Here’s SafeBuffer:
package main
import (
"bytes"
"fmt"
"sync"
)
type SafeBuffer struct {
b bytes.Buffer // Underlying buffer for data
m sync.Mutex // Mutex to lock access
}
func (b *SafeBuffer) Write(p []byte) (n int, err error) {
b.m.Lock() // Lock before writing
defer b.m.Unlock() // Unlock when done
return b.b.Write(p)
}
func (b *SafeBuffer) String() string {
b.m.Lock() // Lock to read safely
defer b.m.Unlock() // Unlock after
return b.b.String()
}
func main() {
var buf SafeBuffer
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
buf.Write([]byte(fmt.Sprintf("Data %d\n", id)))
}(i)
}
wg.Wait()
fmt.Print(buf.String())
}
Output:
Data 0
Data 1
Data 2
Now each write is protected, and the data stays clean.
Step 2: Safe Writers
Next, we need to handle stdout and stderr safely.
Here’s safeWriter to serialize writes:
package main
import (
"bytes"
"fmt"
"sync"
)
// SafeBuffer as defined above
type SafeBuffer struct {
b bytes.Buffer
m sync.Mutex
}
func (b *SafeBuffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Write(p)
}
type safeWriter struct {
buf *SafeBuffer // Buffer to write to
lock sync.Mutex // Extra lock for writer
}
func (w *safeWriter) Write(p []byte) (n int, err error) {
w.lock.Lock() // Lock the writer
defer w.lock.Unlock()
return w.buf.Write(p) // Write to safe buffer
}
func main() {
var buf SafeBuffer
writer := &safeWriter{buf: &buf}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
writer.Write([]byte(fmt.Sprintf("Line %d\n", id)))
}(i)
}
wg.Wait()
fmt.Print(buf.String())
}
Output:
Line 0
Line 1
Line 2
This keeps each command’s output intact.
Step 3: Command Results
We want a clean way to return results.
Here’s CommandResult:
package main
import "fmt"
type CommandResult struct {
ExitCode int // Command’s exit status
Stdout string // Output from stdout
Stderr string // Output from stderr
}
func main() {
result := CommandResult{
ExitCode: 0,
Stdout: "All good",
Stderr: "",
}
fmt.Printf("Exit: %d, Out: %s, Err: %s\n", result.ExitCode, result.Stdout, result.Stderr)
}
Output:
Exit: 0, Out: All good, Err:
It’s straightforward and gives us everything we need.
Step 4: Building safecmd.go
Let’s put it together in safecmd.go.
Here’s the core function:
package main
import (
"bytes"
"fmt"
"os/exec"
"sync"
)
// SafeBuffer and safeWriter as above
type SafeBuffer struct {
b bytes.Buffer
m sync.Mutex
}
func (b *SafeBuffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Write(p)
}
func (b *SafeBuffer) String() string {
b.m.Lock()
defer b.m.Unlock()
return b.b.String()
}
type safeWriter struct {
buf *SafeBuffer
lock sync.Mutex
}
func (w *safeWriter) Write(p []byte) (n int, err error) {
w.lock.Lock()
defer w.lock.Unlock()
return w.buf.Write(p)
}
type CommandResult struct {
ExitCode int
Stdout string
Stderr string
}
func ExecCommand(name string, args ...string) (CommandResult, error) {
var result CommandResult
var stdout, stderr SafeBuffer // Buffers for output
// Set up safe writers
stdoutWriter := &safeWriter{buf: &stdout}
stderrWriter := &safeWriter{buf: &stderr}
cmd := exec.Command(name, args...) // Create command
cmd.Stdout = stdoutWriter // Assign safe stdout
cmd.Stderr = stderrWriter // Assign safe stderr
err := cmd.Run() // Execute the command
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode() // Capture exit code on error
}
}
result.Stdout = stdout.String() // Store stdout
result.Stderr = stderr.String() // Store stderr
return result, err
}
func main() {
result, err := ExecCommand("echo", "Hello, safecmd!")
if err != nil {
fmt.Printf("Error: %v\n", err)
}
fmt.Printf("Exit: %d, Out: %s, Err: %s\n", result.ExitCode, result.Stdout, result.Stderr)
}
Output:
Exit: 0, Out: Hello, safecmd!
, Err:
It’s simple and works reliably.
Step 5: Testing with Goroutines
Let’s test it with concurrency:
package main
import (
"bytes"
"fmt"
"os/exec"
"sync"
)
// SafeBuffer, safeWriter, CommandResult, ExecCommand as above
type SafeBuffer struct {
b bytes.Buffer
m sync.Mutex
}
func (b *SafeBuffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Write(p)
}
func (b *SafeBuffer) String() string {
b.m.Lock()
defer b.m.Unlock()
return b.b.String()
}
type safeWriter struct {
buf *SafeBuffer
lock sync.Mutex
}
func (w *safeWriter) Write(p []byte) (n int, err error) {
w.lock.Lock()
defer w.lock.Unlock()
return w.buf.Write(p)
}
type CommandResult struct {
ExitCode int
Stdout string
Stderr string
}
func ExecCommand(name string, args ...string) (CommandResult, error) {
var result CommandResult
var stdout, stderr SafeBuffer
stdoutWriter := &safeWriter{buf: &stdout}
stderrWriter := &safeWriter{buf: &stderr}
cmd := exec.Command(name, args...)
cmd.Stdout = stdoutWriter
cmd.Stderr = stderrWriter
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
}
}
result.Stdout = stdout.String()
result.Stderr = stderr.String()
return result, err
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
result, err := ExecCommand("echo", fmt.Sprintf("Hello from %d", id))
if err != nil {
fmt.P```
Goroutine 0: Hello from 0
Goroutine 1: Hello from 1
Goroutine 2: Hello from 2
Each goroutine gets its own clean output.
`safecmd.go` takes the pain out of concurrent commands.
It stops buffer corruption in its tracks.
It keeps outputs separate and readable.
You can drop it into your project and use it right away. It’s a small tool that saves big headaches.
*AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.
git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.*
Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.
⭐ Star it on GitHub:
HexmosTech
/
git-lrc
Free, Unlimited AI Code Reviews That Run on Commit
AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.
git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.
See It In Action
See git-lrc catch serious security issues such as leaked credentials, expensive cloud operations, and sensitive material in log statements
git-lrc-intro-60s.mp4
Why
- 🤖 AI agents silently break things. Code removed. Logic changed. Edge cases gone. You won't notice until production.
- 🔍 Catch it before it ships. AI-powered inline comments show you exactly what changed and what looks wrong.
- 🔁 Build a habit, ship better code. Regular review → fewer bugs → more robust code → better results in your team.
- 🔗 Why git? Git is universal. Every editor, every IDE, every AI…
Top comments (2)
Hi, wouldn't it make more sense to structure the routines as workers in a worker pool. Each routine/Struct would have it's own buffered reader and writer and maybe a Chan to deliver results?
Sharing buffers was bound to be problematic
I'd think of that as another layer of convenience on top of
safecmd. Many solutions possible, this is just one way of getting there.