Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ gs_*
.vscode

dist
gih_dev
gih_dev

.worktrees/
tasks/
14 changes: 11 additions & 3 deletions gih/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

// set by build
var (
version = "0.15.0"
version = "0.16.0"
goversion = "1.26.2"
)

Expand Down Expand Up @@ -75,7 +75,7 @@ func main() {
writer := os.Stdout
errWriter := os.Stderr

err := (&syncer.Sync{
summary, err := (&syncer.Sync{
TargetDir: *targetDir,
IgnoreDir: *ignoreDir,
TimeOut: *timeout,
Expand All @@ -86,7 +86,15 @@ func main() {
Writer: printer.NewPrinter(writer, errWriter),
}).Run()

// Map run outcome to exit codes per README spec:
// 0 - all operations completed successfully
// 1 - setup error (invalid args, no repos, invalid regex/timeout)
// 2 - some repositories failed or timed out
if err != nil {
panic(err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if summary != nil && summary.HasFailures() {
os.Exit(2)
}
}
58 changes: 35 additions & 23 deletions printer/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"os"
"strings"
"sync"
"text/template"

"github.com/fatih/color"
Expand Down Expand Up @@ -44,8 +45,24 @@ const repoErrTmpl = `
{{ range .Repos }}- {{ . }}
{{ end }}`

// Templates are parsed once at package init. text/template.Template.Execute is
// safe for concurrent use, so the same parsed template can be shared across
// goroutines without re-parsing on every call.
var (
successTpl = template.Must(template.New("success").Funcs(helpers).Parse(successTmpl))
errTpl = template.Must(template.New("err").Funcs(helpers).Parse(errTmpl))
cmdTpl = template.Must(template.New("cmd").Funcs(helpers).Parse(cmdTmpl))
msgTpl = template.Must(template.New("msg").Funcs(helpers).Parse(msgTmpl))
msgErrTpl = template.Must(template.New("msgErr").Funcs(helpers).Parse(msgErrTmpl))
repoErrTpl = template.Must(template.New("repoErr").Funcs(helpers).Parse(repoErrTmpl))
)

// Printer is struct
type Printer struct {
// mu serialises template.Execute calls so that the multiple Write calls
// emitted per Execute don't interleave across goroutines, even when
// writer and errWriter end up at the same TTY.
mu sync.Mutex
writer io.Writer
errWriter io.Writer
}
Expand Down Expand Up @@ -78,9 +95,9 @@ func (p *Printer) PrintCmd(cmd string, options []string) {
Cmd string
Ops string
}
t := template.Must(template.New("item").Funcs(helpers).Parse(cmdTmpl))
err := t.Execute(p.writer, cmds{Cmd: cmd, Ops: strings.Join(options, " ")})
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := cmdTpl.Execute(p.writer, cmds{Cmd: cmd, Ops: strings.Join(options, " ")}); err != nil {
log.Println(err)
}
}
Expand All @@ -90,9 +107,9 @@ func (p *Printer) PrintMsg(msg string) {
type message struct {
Msg string
}
t := template.Must(template.New("msg").Funcs(helpers).Parse(msgTmpl))
err := t.Execute(p.writer, message{Msg: msg})
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := msgTpl.Execute(p.writer, message{Msg: msg}); err != nil {
log.Println(err)
}
}
Expand All @@ -102,9 +119,9 @@ func (p *Printer) PrintMsgErr(msg string) {
type message struct {
Msg string
}
t := template.Must(template.New("msg").Funcs(helpers).Parse(msgErrTmpl))
err := t.Execute(p.writer, message{Msg: msg})
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := msgErrTpl.Execute(p.writer, message{Msg: msg}); err != nil {
Comment thread
takecy marked this conversation as resolved.
log.Println(err)
}
}
Expand All @@ -115,33 +132,28 @@ func (p *Printer) PrintRepoErr(msg string, repos []string) {
Msg string
Repos []string
}
t := template.Must(template.New("msg").Funcs(helpers).Parse(repoErrTmpl))
err := t.Execute(p.writer, message{Msg: msg, Repos: repos})
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := repoErrTpl.Execute(p.writer, message{Msg: msg, Repos: repos}); err != nil {
Comment thread
takecy marked this conversation as resolved.
log.Println(err)
}
}

// Print prints result
func (p *Printer) Print(res Result) {
err := t(true).Execute(p.writer, res)
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := successTpl.Execute(p.writer, res); err != nil {
log.Println(err)
}
}

// Error prints error
func (p *Printer) Error(res Result) {
res.Msg = res.Err.Error()
err := t(false).Execute(p.errWriter, res)
if err != nil {
p.mu.Lock()
defer p.mu.Unlock()
if err := errTpl.Execute(p.errWriter, res); err != nil {
log.Println(err)
}
}

func t(isSuccess bool) *template.Template {
if isSuccess {
return template.Must(template.New("item").Funcs(helpers).Parse(successTmpl))
}
return template.Must(template.New("item").Funcs(helpers).Parse(errTmpl))
}
188 changes: 188 additions & 0 deletions printer/print_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package printer

import (
"bytes"
"errors"
"io"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/matryer/is"
)

func TestPrinter_PrintMsg_ConcurrentSafe(t *testing.T) {
is := is.New(t)

var buf bytes.Buffer
p := NewPrinter(&buf, &buf)

const N = 200
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.PrintMsg("hello")
}()
}
wg.Wait()

out := strings.TrimRight(buf.String(), "\n")
lines := strings.Split(out, "\n")
is.Equal(len(lines), N)
for _, l := range lines {
is.True(strings.Contains(l, "hello"))
}
}

func TestPrinter_Print_ConcurrentSafe(t *testing.T) {
// successTmpl: "{{.Repo}}\n {{.Msg}}\n" — 2 lines per call. If a lock is
// missing here, the two lines from one call can interleave with another
// call's lines, breaking the strict pair structure verified below.
is := is.New(t)

var buf bytes.Buffer
p := NewPrinter(&buf, &buf)

const N = 200
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.Print(Result{Repo: "/path/repo", Msg: "ok"})
}()
}
wg.Wait()

lines := strings.Split(buf.String(), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
is.Equal(len(lines), N*2)

for i := 0; i < len(lines); i += 2 {
is.True(strings.Contains(lines[i], "/path/repo"))
is.True(strings.HasPrefix(lines[i+1], " "))
is.True(strings.Contains(lines[i+1], "ok"))
}
}

func TestPrinter_Error_ConcurrentSafe(t *testing.T) {
is := is.New(t)

var errBuf bytes.Buffer
p := NewPrinter(io.Discard, &errBuf)

const N = 100
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.Error(Result{Repo: "/path/r", Err: errors.New("boom")})
}()
}
wg.Wait()

lines := strings.Split(errBuf.String(), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
is.Equal(len(lines), N*2)
for i := 0; i < len(lines); i += 2 {
is.True(strings.Contains(lines[i], "/path/r"))
is.True(strings.HasPrefix(lines[i+1], " "))
is.True(strings.Contains(lines[i+1], "boom"))
}
}

// concurrencyProbe is a thin io.Writer that records the maximum number of
// concurrent in-flight Write calls. Used to verify that a single mutex covers
// BOTH writer and errWriter in Printer.
type concurrencyProbe struct {
inflight atomic.Int32
peak atomic.Int32
}

func (c *concurrencyProbe) Write(p []byte) (int, error) {
n := c.inflight.Add(1)
for {
cur := c.peak.Load()
if n <= cur {
break
}
if c.peak.CompareAndSwap(cur, n) {
break
}
}
// Tiny sleep widens the race window so that, if Print and Error were
// protected by separate mutexes, the probe would observe inflight >= 2.
time.Sleep(20 * time.Microsecond)
c.inflight.Add(-1)
return len(p), nil
}

func TestPrinter_SharedMutex_AcrossWriterAndErrWriter(t *testing.T) {
// Wires both writer and errWriter to the same probe. Print uses writer,
// Error uses errWriter. With a single mutex covering both, no two Writes
// can be in-flight at once → peak == 1. With separate mutexes, peak >= 2
// would be observed with high probability.
is := is.New(t)

probe := &concurrencyProbe{}
p := NewPrinter(probe, probe)

const N = 200
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
p.Print(Result{Repo: "/r", Msg: "ok"})
} else {
p.Error(Result{Repo: "/r", Err: errors.New("boom")})
}
}(i)
}
wg.Wait()

is.Equal(int(probe.peak.Load()), 1)
}

func TestPrinter_MixedPrints_ConcurrentSafe(t *testing.T) {
// Smoke test exercising every public Printer method concurrently. Catches
// any lock omission via the race detector even when message-shape tests
// don't apply (e.g. PrintCmd, PrintRepoErr).
var buf bytes.Buffer
p := NewPrinter(&buf, &buf)

var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
switch i % 7 {
case 0:
p.PrintMsg("m")
case 1:
p.PrintMsgErr("e")
case 2:
p.PrintRepoErr("re", []string{"a", "b"})
case 3:
p.PrintCmd("status", []string{"--short"})
case 4:
p.Print(Result{Repo: "/path/r", Msg: "ok"})
case 5:
p.Error(Result{Repo: "/path/r", Err: errors.New("boom")})
case 6:
p.PrintMsg("another")
}
}(i)
}
wg.Wait()
}
Loading
Loading