diff --git a/README.md b/README.md index 7c6a082..866cc83 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,30 @@ gih pull # Done! All repositories updated in parallel It is just a tool to do this. It does nothing else. I created it because I was tired of managing dozens of microservice repositories for the projects I work on. +### Output + +`gih` streams one completion line per repository as work finishes, then +prints a bordered summary table with totals at the end: + +``` +==> Running pull origin master on 3 repositories +✓ takecy/git-here Already up to date. 0.4s +✗ takecy/adk-test fatal: couldn't find remote ref master 1.2s +✓ takecy/another-repo Already up to date. 0.5s +==> Summary ++-------------------------+--------+----------+----------------------------------------+ +| Repository | Status | Duration | Message | ++-------------------------+--------+----------+----------------------------------------+ +| takecy/git-here | ✓ | 0.4s | Already up to date. | +| takecy/adk-test | ✗ | 1.2s | fatal: couldn't find remote ref master | +| takecy/another-repo | ✓ | 0.5s | Already up to date. | ++-------------------------+--------+----------+----------------------------------------+ +Total: 3 Success: 2 Failed: 1 Timeout: 0 Elapsed: 1.2s +``` + +Failed rows are colored red and timed-out rows yellow when the output +target is a terminal. + ## Table of Contents diff --git a/gih/main.go b/gih/main.go index 93dff25..bffeebb 100644 --- a/gih/main.go +++ b/gih/main.go @@ -66,7 +66,6 @@ func main() { fmt.Printf("args: targetDir: %s ignoreDir: %s concurrency: %d timeout: %v\n", *targetDir, *ignoreDir, *conNum, *timeout) writer := os.Stdout - errWriter := os.Stderr summary, err := (&syncer.Sync{ TargetDir: *targetDir, @@ -75,8 +74,8 @@ func main() { Command: flag.Arg(0), Options: flag.Args()[1:], ConNum: *conNum, - Gitter: syncer.NewGitter(writer, errWriter), - Writer: printer.NewPrinter(writer, errWriter), + Gitter: syncer.NewGitter(writer, os.Stderr), + Writer: printer.NewPrinter(writer), }).Run() // Map run outcome to exit codes per README spec: diff --git a/printer/print.go b/printer/print.go index 828aa0d..9151680 100644 --- a/printer/print.go +++ b/printer/print.go @@ -1,161 +1,312 @@ package printer import ( + "fmt" "io" "log" "os" "strings" "sync" - "text/template" + "time" + "unicode/utf8" "github.com/fatih/color" ) -var helpers = template.FuncMap{ - "magenta": color.MagentaString, - "yellow": color.YellowString, - "green": color.GreenString, - "black": color.BlackString, - "white": color.WhiteString, - "blue": color.BlueString, - "cyan": color.CyanString, - "red": color.RedString, -} - -const successTmpl = `{{.Repo }} - {{.Msg }} -` - -const errTmpl = `{{.Repo }} - {{.Msg | red}} -` - -const cmdTmpl = `Git command is - {{.Cmd | green}} {{.Ops | green}} -` - -const msgTmpl = `{{.Msg | green}} -` - -const msgErrTmpl = `{{.Msg | red}} -` - -const repoErrTmpl = ` -{{.Msg | red}} - {{ 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)) +// Status classifies a per-repo result for table rendering and icon selection. +type Status int + +const ( + StatusSuccess Status = iota + StatusFailed + StatusTimeout ) -// 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 +// Outcome carries every field needed to render both the streaming +// "completed" line and the final summary table row for a single repository. +type Outcome struct { + // Repo is the absolute path of the repository. Retained on the struct + // so callers (e.g. syncer.runStats) can key per-status buckets by + // stable absolute path even after the streaming line / summary row + // have been rendered with the shortened Display. + Repo string + + // Display is the shortened display name (e.g. "dxe-ai/agent"). + Display string + + // Status classifies the outcome (success / failed / timeout). + Status Status + + // Duration is the per-repo elapsed time. + Duration time.Duration + + // Message is the first non-empty line of git stdout (success) or + // stderr (failure) — used in the streaming line and table cell. + Message string + + // Err is populated only when Status != StatusSuccess. + Err error } -// Result is output -type Result struct { - Repo string - Msg string - Err error +// Summary is a compact view of the per-status counts. Mirrors syncer.RunSummary +// but is duplicated here to avoid an import cycle (syncer already imports +// printer). +type Summary struct { + Succeeded int + Failed int + TimedOut int +} + +// Total reports the aggregate count. +func (s Summary) Total() int { return s.Succeeded + s.Failed + s.TimedOut } + +// Printer is struct +type Printer struct { + // mu serialises every public method's writes so that the multiple Write + // calls per render don't interleave across goroutines. + mu sync.Mutex + writer io.Writer } // NewPrinter is constructor -func NewPrinter(writer, errWriter io.Writer) *Printer { +func NewPrinter(writer io.Writer) *Printer { if writer == nil { writer = os.Stdout } - if errWriter == nil { - errWriter = os.Stderr - } return &Printer{ - writer: writer, - errWriter: errWriter, + writer: writer, } } -// PrintCmd prints command detail -func (p *Printer) PrintCmd(cmd string, options []string) { - type cmds struct { - Cmd string - Ops string - } +// PrintMsg prints message +func (p *Printer) PrintMsg(msg string) { p.mu.Lock() defer p.mu.Unlock() - if err := cmdTpl.Execute(p.writer, cmds{Cmd: cmd, Ops: strings.Join(options, " ")}); err != nil { + if _, err := fmt.Fprintln(p.writer, color.GreenString(msg)); err != nil { log.Println(err) } } -// PrintMsg prints message -func (p *Printer) PrintMsg(msg string) { - type message struct { - Msg string - } +// PrintHeader emits the run-start banner: "==> Running on N +// repositories". +func (p *Printer) PrintHeader(cmd string, options []string, total int) { p.mu.Lock() defer p.mu.Unlock() - if err := msgTpl.Execute(p.writer, message{Msg: msg}); err != nil { + + parts := []string{cmd} + parts = append(parts, options...) + cmdLine := strings.Join(parts, " ") + + header := fmt.Sprintf("==> Running %s on %d repositories", cmdLine, total) + if _, err := fmt.Fprintln(p.writer, color.GreenString(header)); err != nil { log.Println(err) } } -// PrintMsgErr prints an error message to errWriter so that callers can -// redirect stderr separately from stdout (consistent with Printer.Error). -func (p *Printer) PrintMsgErr(msg string) { - type message struct { - Msg string - } +// PrintRepoLine emits a single line summarising one repository's outcome. +// Format: " ". +func (p *Printer) PrintRepoLine(o Outcome) { p.mu.Lock() defer p.mu.Unlock() - if err := msgErrTpl.Execute(p.errWriter, message{Msg: msg}); err != nil { + + line := fmt.Sprintf("%s %-30s %-40s %s", + statusIconColored(o.Status), + o.Display, + truncate(o.Message, 40), + formatDuration(o.Duration), + ) + if _, err := fmt.Fprintln(p.writer, line); err != nil { log.Println(err) } } -// PrintRepoErr prints a header plus a list of repository paths to errWriter -// (matching the stderr semantics of PrintMsgErr / Error). -func (p *Printer) PrintRepoErr(msg string, repos []string) { - type message struct { - Msg string - Repos []string - } +// PrintSummaryTable renders a "==> Summary" heading, the bordered 4-column +// table (Repository / Status / Duration / Message), and a totals line +// including elapsed wall-clock time. +func (p *Printer) PrintSummaryTable(outcomes []Outcome, summary Summary, elapsed time.Duration) { p.mu.Lock() defer p.mu.Unlock() - if err := repoErrTpl.Execute(p.errWriter, message{Msg: msg, Repos: repos}); err != nil { + + if _, err := fmt.Fprintln(p.writer, color.GreenString("==> Summary")); err != nil { log.Println(err) } -} -// Print prints result -func (p *Printer) Print(res Result) { - p.mu.Lock() - defer p.mu.Unlock() - if err := successTpl.Execute(p.writer, res); err != nil { + header := []string{"Repository", "Status", "Duration", "Message"} + rows := make([][]string, 0, len(outcomes)) + rowColors := make([]func(string) string, 0, len(outcomes)) + for _, o := range outcomes { + rows = append(rows, []string{ + o.Display, + statusIcon(o.Status), + formatDuration(o.Duration), + truncate(o.Message, 60), + }) + rowColors = append(rowColors, rowColorFor(o.Status)) + } + renderTable(p.writer, header, rows, rowColors) + + totals := fmt.Sprintf("Total: %d Success: %d Failed: %d Timeout: %d Elapsed: %s", + summary.Total(), + summary.Succeeded, + summary.Failed, + summary.TimedOut, + formatDuration(elapsed), + ) + if _, err := fmt.Fprintln(p.writer, totals); err != nil { log.Println(err) } } -// Error prints error -func (p *Printer) Error(res Result) { - res.Msg = res.Err.Error() - p.mu.Lock() - defer p.mu.Unlock() - if err := errTpl.Execute(p.errWriter, res); err != nil { +// writeLine is a small helper that funnels Fprintln errors through log so the +// errcheck linter is satisfied while preserving the same write semantics +// every other Printer method uses. +func writeLine(w io.Writer, s string) { + if _, err := fmt.Fprintln(w, s); err != nil { log.Println(err) } } + +// statusIcon returns a plain (uncolored) status glyph used inside table cells +// where ANSI codes would interfere with width calculation. +func statusIcon(s Status) string { + switch s { + case StatusSuccess: + return "✓" + case StatusFailed: + return "✗" + case StatusTimeout: + return "⏱" + } + return "?" +} + +// rowColorFor returns a function that wraps a fully-built table row in the +// ANSI color appropriate for the given Status, or nil to leave the row +// uncolored. Coloring is applied AFTER width calculation and padding so the +// table stays aligned even when the rendered cell contains no escape codes. +func rowColorFor(s Status) func(string) string { + switch s { + case StatusFailed: + return func(s string) string { return color.New(color.FgRed).Sprint(s) } + case StatusTimeout: + return func(s string) string { return color.New(color.FgYellow).Sprint(s) } + } + return nil +} + +// statusIconColored returns the colored variant for use in streaming repo +// lines (outside the table). +func statusIconColored(s Status) string { + switch s { + case StatusSuccess: + return color.GreenString("✓") + case StatusFailed: + return color.RedString("✗") + case StatusTimeout: + return color.YellowString("⏱") + } + return "?" +} + +// formatDuration renders a duration in a compact, fixed style: "0.4s", "12s", +// "1m23s". Avoids the noisy "1.234567ms" shape from Duration.String. +func formatDuration(d time.Duration) string { + if d <= 0 { + return "0s" + } + if d < time.Second { + ms := float64(d) / float64(time.Millisecond) + return fmt.Sprintf("%.0fms", ms) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", float64(d)/float64(time.Second)) + } + return d.Round(time.Second).String() +} + +// truncate caps s to maxRunes runes (visual cells, approximately) by replacing +// the tail with an ellipsis so the table stays aligned even for long messages. +func truncate(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + if maxRunes <= 1 { + return "…" + } + runes := []rune(s) + return string(runes[:maxRunes-1]) + "…" +} + +// renderTable draws a +----+ bordered table using rune-count widths for +// alignment. Treats every rune as a single visual cell; that is correct for +// the ASCII-heavy data we render plus the three single-width status glyphs. +// +// rowColors, when non-nil and the matching index is non-nil, is applied to a +// fully-built row line (including borders) AFTER width calculation. Pass nil +// for the entire slice or nil for individual entries to leave rows uncolored. +func renderTable(w io.Writer, header []string, rows [][]string, rowColors []func(string) string) { + if len(header) == 0 { + return + } + widths := make([]int, len(header)) + for i, h := range header { + widths[i] = utf8.RuneCountInString(h) + } + for _, row := range rows { + for i, cell := range row { + if i >= len(widths) { + continue + } + if cw := utf8.RuneCountInString(cell); cw > widths[i] { + widths[i] = cw + } + } + } + + border := buildBorder(widths) + writeLine(w, border) + writeLine(w, buildRow(header, widths)) + writeLine(w, border) + for i, row := range rows { + line := buildRow(row, widths) + if i < len(rowColors) && rowColors[i] != nil { + line = rowColors[i](line) + } + writeLine(w, line) + } + writeLine(w, border) +} + +func buildBorder(widths []int) string { + var b strings.Builder + b.WriteByte('+') + for _, w := range widths { + b.WriteString(strings.Repeat("-", w+2)) + b.WriteByte('+') + } + return b.String() +} + +func buildRow(cells []string, widths []int) string { + var b strings.Builder + b.WriteByte('|') + for i, w := range widths { + cell := "" + if i < len(cells) { + cell = cells[i] + } + b.WriteByte(' ') + b.WriteString(cell) + pad := w - utf8.RuneCountInString(cell) + if pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + b.WriteByte(' ') + b.WriteByte('|') + } + return b.String() +} diff --git a/printer/print_test.go b/printer/print_test.go index 95c81ca..445b5a8 100644 --- a/printer/print_test.go +++ b/printer/print_test.go @@ -2,11 +2,8 @@ package printer import ( "bytes" - "errors" - "io" "strings" "sync" - "sync/atomic" "testing" "time" @@ -17,7 +14,7 @@ func TestPrinter_PrintMsg_ConcurrentSafe(t *testing.T) { is := is.New(t) var buf bytes.Buffer - p := NewPrinter(&buf, &buf) + p := NewPrinter(&buf) const N = 200 var wg sync.WaitGroup @@ -38,151 +35,151 @@ func TestPrinter_PrintMsg_ConcurrentSafe(t *testing.T) { } } -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. +func TestPrinter_PrintRepoLine_ConcurrentSafe(t *testing.T) { + // Each PrintRepoLine call must produce exactly one line and not + // interleave with concurrent calls. is := is.New(t) var buf bytes.Buffer - p := NewPrinter(&buf, &buf) + p := NewPrinter(&buf) const N = 200 var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Add(1) - go func() { + go func(i int) { defer wg.Done() - p.Print(Result{Repo: "/path/repo", Msg: "ok"}) - }() + st := Status(i % 3) + p.PrintRepoLine(Outcome{ + Repo: "/path/repo", + Display: "org/repo", + Status: st, + Duration: 100 * time.Millisecond, + Message: "msg", + }) + }(i) } 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")) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + is.Equal(len(lines), N) + for _, l := range lines { + is.True(strings.Contains(l, "org/repo")) + is.True(strings.Contains(l, "msg")) } } -func TestPrinter_Error_ConcurrentSafe(t *testing.T) { +func TestPrinter_PrintSummaryTable(t *testing.T) { + t.Parallel() 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() + var out bytes.Buffer + p := NewPrinter(&out) - 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")) + outcomes := []Outcome{ + {Display: "org/a", Status: StatusSuccess, Duration: 400 * time.Millisecond, Message: "Already up to date."}, + {Display: "org/b", Status: StatusFailed, Duration: 200 * time.Millisecond, Message: "fatal: ..."}, + {Display: "org/c", Status: StatusTimeout, Duration: 1 * time.Second, Message: "timeout"}, } + summary := Summary{Succeeded: 1, Failed: 1, TimedOut: 1} + p.PrintSummaryTable(outcomes, summary, 1500*time.Millisecond) + + got := out.String() + + is.True(strings.Contains(got, "==> Summary")) + is.True(strings.Contains(got, "Repository")) + is.True(strings.Contains(got, "Status")) + is.True(strings.Contains(got, "Duration")) + is.True(strings.Contains(got, "Message")) + is.True(strings.Contains(got, "org/a")) + is.True(strings.Contains(got, "org/b")) + is.True(strings.Contains(got, "org/c")) + is.True(strings.Contains(got, "+--")) + is.True(strings.Contains(got, "Total: 3")) + is.True(strings.Contains(got, "Success: 1")) + is.True(strings.Contains(got, "Failed: 1")) + is.True(strings.Contains(got, "Timeout: 1")) } -// 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. +func TestPrinter_PrintHeader(t *testing.T) { + t.Parallel() is := is.New(t) - probe := &concurrencyProbe{} - p := NewPrinter(probe, probe) + var out bytes.Buffer + p := NewPrinter(&out) - 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() + p.PrintHeader("pull", []string{"origin", "main"}, 8) - is.Equal(int(probe.peak.Load()), 1) + got := out.String() + is.True(strings.Contains(got, "==> Running")) + is.True(strings.Contains(got, "pull origin main")) + is.True(strings.Contains(got, "8 repositories")) } 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). + // any lock omission via the race detector. var buf bytes.Buffer - p := NewPrinter(&buf, &buf) + p := NewPrinter(&buf) var wg sync.WaitGroup for i := 0; i < 200; i++ { wg.Add(1) go func(i int) { defer wg.Done() - switch i % 7 { + switch i % 4 { case 0: p.PrintMsg("m") case 1: - p.PrintMsgErr("e") + p.PrintHeader("status", []string{"--short"}, 3) case 2: - p.PrintRepoErr("re", []string{"a", "b"}) + p.PrintRepoLine(Outcome{ + Display: "org/r", + Status: Status(i % 3), + Message: "ok", + }) 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") + p.PrintSummaryTable( + []Outcome{{Display: "org/r", Status: StatusSuccess}}, + Summary{Succeeded: 1}, + time.Second, + ) } }(i) } wg.Wait() } + +func TestPrinter_NewPrinter_DefaultsToOsStdoutWhenNil(t *testing.T) { + t.Parallel() + is := is.New(t) + + p := NewPrinter(nil) + is.True(p.writer != nil) +} + +func TestRowColorFor(t *testing.T) { + t.Run("success has no color decorator", func(t *testing.T) { + t.Parallel() + is := is.New(t) + is.True(rowColorFor(StatusSuccess) == nil) + }) + + t.Run("failed returns a non-nil decorator", func(t *testing.T) { + t.Parallel() + is := is.New(t) + fn := rowColorFor(StatusFailed) + is.True(fn != nil) + // Decorator must wrap input non-empty (color codes added) without + // dropping the original payload. + is.True(strings.Contains(fn("X"), "X")) + }) + + t.Run("timeout returns a non-nil decorator", func(t *testing.T) { + t.Parallel() + is := is.New(t) + fn := rowColorFor(StatusTimeout) + is.True(fn != nil) + is.True(strings.Contains(fn("X"), "X")) + }) +} diff --git a/syncer/syncer.go b/syncer/syncer.go index 26f4fd8..1797f65 100644 --- a/syncer/syncer.go +++ b/syncer/syncer.go @@ -2,11 +2,10 @@ package syncer import ( "context" - "fmt" "path/filepath" "regexp" + "strings" "sync" - "sync/atomic" "time" "github.com/pkg/errors" @@ -56,32 +55,29 @@ type Sync struct { } // runStats accumulates per-repository outcomes safely from concurrent goroutines. -// Each bucket is just a list of repository paths — the per-repo error message -// is already streamed to stderr at execution time via PrintMsgErr, so there is -// no need to retain it here. +// It retains both the ordered Outcome list (for the final summary table) and +// the per-status path buckets (for cheap len() access used by RunSummary and +// existing tests). type runStats struct { mu sync.Mutex succeeded []string failed []string timedOut []string + outcomes []printer.Outcome } -func (s *runStats) addSuccess(r string) { +func (s *runStats) addOutcome(o printer.Outcome) { s.mu.Lock() defer s.mu.Unlock() - s.succeeded = append(s.succeeded, r) -} - -func (s *runStats) addFailed(r string) { - s.mu.Lock() - defer s.mu.Unlock() - s.failed = append(s.failed, r) -} - -func (s *runStats) addTimedOut(r string) { - s.mu.Lock() - defer s.mu.Unlock() - s.timedOut = append(s.timedOut, r) + s.outcomes = append(s.outcomes, o) + switch o.Status { + case printer.StatusSuccess: + s.succeeded = append(s.succeeded, o.Repo) + case printer.StatusFailed: + s.failed = append(s.failed, o.Repo) + case printer.StatusTimeout: + s.timedOut = append(s.timedOut, o.Repo) + } } // summary builds a public RunSummary snapshot of the current stats. @@ -95,6 +91,17 @@ func (s *runStats) summary() *RunSummary { } } +// printerSummary mirrors RunSummary onto the printer-side type used by +// PrintSummaryTable, avoiding an import cycle. +func (s *runStats) printerSummary() printer.Summary { + rs := s.summary() + return printer.Summary{ + Succeeded: rs.Succeeded, + Failed: rs.Failed, + TimedOut: rs.TimedOut, + } +} + // Run discovers repositories, applies filters, and executes the configured // git command across the matching set. It returns a summary of per-repo // outcomes, or an error for setup failures (no repositories, invalid regex, @@ -110,9 +117,6 @@ func (s *Sync) Run() (*RunSummary, error) { return nil, errors.New("no git repositories found in current directory") } - fmt.Printf("repositories are found: (%d)\n", len(dirs)) - s.Writer.PrintCmd(s.Command, s.Options) - repos, err := s.filterRepos(dirs) if err != nil { return nil, err @@ -122,15 +126,19 @@ func (s *Sync) Run() (*RunSummary, error) { s.Writer.PrintMsg("No target repositories.") return &RunSummary{}, nil } - s.Writer.PrintMsg(fmt.Sprintf("target repositories: (%d)", len(repos))) perRepoTimeout, err := time.ParseDuration(s.TimeOut) if err != nil { return nil, errors.Wrapf(err, "invalid timeout value: %s", s.TimeOut) } + s.Writer.PrintHeader(s.Command, s.Options, len(repos)) + + runStart := time.Now() stats := s.execute(context.Background(), repos, perRepoTimeout) - s.printSummary(stats) + elapsed := time.Since(runStart) + + s.Writer.PrintSummaryTable(stats.outcomes, stats.printerSummary(), elapsed) return stats.summary(), nil } @@ -172,12 +180,10 @@ func (s *Sync) filterRepos(dirs []string) ([]string, error) { // execute runs the git command across all repos in parallel, throttled by // ConNum. Each invocation is bounded by perRepoTimeout via a derived context; // the parent context is *not* timed out so a slow repo never starves the -// remaining ones. +// remaining ones. Each goroutine builds a printer.Outcome, stores it in +// runStats, and emits a single completed line via PrintRepoLine. func (s *Sync) execute(parent context.Context, repos []string, perRepoTimeout time.Duration) *runStats { stats := &runStats{} - total := len(repos) - var done atomic.Int64 - start := time.Now() eg := &errgroup.Group{} if s.ConNum > 0 { @@ -187,63 +193,99 @@ func (s *Sync) execute(parent context.Context, repos []string, perRepoTimeout ti for _, r := range repos { r := r eg.Go(func() error { + started := time.Now() ctx, cancel := context.WithTimeout(parent, perRepoTimeout) defer cancel() - err := s.execCmd(ctx, r) + absPath, absErr := filepath.Abs(r) + if absErr != nil { + o := printer.Outcome{ + Repo: r, + Display: displayName(r), + Status: printer.StatusFailed, + Duration: time.Since(started), + Message: absErr.Error(), + Err: errors.Wrapf(absErr, "get.abs.failed: %s", r), + } + stats.addOutcome(o) + s.Writer.PrintRepoLine(o) + return nil + } + + msg, errMsg, err := s.Gitter.Git(ctx, s.Command, absPath, s.Options...) + o := printer.Outcome{ + Repo: absPath, + Display: displayName(absPath), + Duration: time.Since(started), + } switch { case err == nil: - stats.addSuccess(r) - s.Writer.PrintMsg(fmt.Sprintf("Success: %s", r)) + o.Status = printer.StatusSuccess + o.Message = firstLine(msg) case errors.Is(ctx.Err(), context.DeadlineExceeded): - stats.addTimedOut(r) - s.Writer.PrintMsgErr(fmt.Sprintf("Timeout: %s", r)) + o.Status = printer.StatusTimeout + o.Message = "timeout" + o.Err = ctx.Err() default: - stats.addFailed(r) - s.Writer.PrintMsgErr(fmt.Sprintf("Failed: %s\n%v", r, err)) + o.Status = printer.StatusFailed + // Prefer the first line of git stderr when present; fall back + // to err.Error() so failures that happen before the child + // process can write to stderr (e.g. the repo was removed + // between discovery and execution, or cmd.Dir cannot be + // entered) still surface actionable text in the per-repo + // line and summary table. + if msg := firstLine(errMsg); msg != "" { + o.Message = msg + } else { + o.Message = err.Error() + } + o.Err = err } - - n := done.Add(1) - s.Writer.PrintMsg(fmt.Sprintf("Done: %d/%d", n, total)) + stats.addOutcome(o) + s.Writer.PrintRepoLine(o) return nil }) } _ = eg.Wait() - s.Writer.PrintMsg(fmt.Sprintf("All done. (%v)", time.Since(start).Round(time.Millisecond))) return stats } -// execCmd is execute git command -func (s *Sync) execCmd(ctx context.Context, d string) error { - absPath, err := filepath.Abs(d) - if err != nil { - return errors.Wrapf(err, "get.abs.failed: %s", d) +// displayName shortens an absolute repository path to its trailing two +// segments for compact display (e.g. ".../dxe-ai/agent" -> "dxe-ai/agent"). +// A bare leaf is returned untouched. +func displayName(p string) string { + p = filepath.Clean(p) + parent, leaf := filepath.Split(p) + parent = strings.TrimRight(parent, string(filepath.Separator)) + // Treat empty / "." / Unix root / Windows drive root (e.g. "C:") as + // "no meaningful parent" so a repo directly under such a root renders + // as the bare leaf instead of "C:/repo". + if parent == "" || parent == "." || parent == string(filepath.Separator) || isDriveRoot(parent) { + return leaf } + return filepath.Base(parent) + "/" + leaf +} - msg, errMsg, err := s.Gitter.Git(ctx, s.Command, absPath, s.Options...) - if err != nil { - return errors.Wrapf(err, "%s", errMsg) +// isDriveRoot reports whether s looks like a Windows drive root such as +// "C:" — i.e. a single ASCII letter followed by a colon and nothing else. +// We intentionally don't gate on runtime.GOOS so the check stays a cheap +// pure helper that's easy to test on any platform. +func isDriveRoot(s string) bool { + if len(s) != 2 || s[1] != ':' { + return false } - s.Writer.Print(printer.Result{Repo: absPath, Msg: msg}) - return nil + c := s[0] + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') } -// printSummary emits the post-run summary: counts, plus the list of failed -// and timed-out repositories. Reuses printer.PrintRepoErr (previously unused). -func (s *Sync) printSummary(stats *runStats) { - stats.mu.Lock() - defer stats.mu.Unlock() - - s.Writer.PrintMsg(fmt.Sprintf( - "Summary: success=%d failed=%d timeout=%d", - len(stats.succeeded), len(stats.failed), len(stats.timedOut), - )) - - if len(stats.failed) > 0 { - s.Writer.PrintRepoErr("Failed repositories:", stats.failed) - } - if len(stats.timedOut) > 0 { - s.Writer.PrintRepoErr("Timed out repositories:", stats.timedOut) +// firstLine returns the first non-empty trimmed line of s, or "" if none. +func firstLine(s string) string { + for _, ln := range strings.Split(s, "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + return ln + } } + return "" } diff --git a/syncer/syncer_test.go b/syncer/syncer_test.go index 9a4f43b..78c90cd 100644 --- a/syncer/syncer_test.go +++ b/syncer/syncer_test.go @@ -26,7 +26,7 @@ func newSyncWithFake(fn execFn, conNum int) *Sync { Command: "status", ConNum: conNum, Gitter: &fakeExecutor{fn: fn}, - Writer: printer.NewPrinter(io.Discard, io.Discard), + Writer: printer.NewPrinter(io.Discard), } } @@ -43,6 +43,7 @@ func TestSync_Execute(t *testing.T) { is.Equal(len(stats.succeeded), 3) is.Equal(len(stats.failed), 0) is.Equal(len(stats.timedOut), 0) + is.Equal(len(stats.outcomes), 3) }) t.Run("partial failure", func(t *testing.T) { @@ -59,6 +60,7 @@ func TestSync_Execute(t *testing.T) { stats := s.execute(context.Background(), []string{"a", "b", "c"}, time.Second) is.Equal(len(stats.succeeded), 2) is.Equal(len(stats.failed), 1) + is.Equal(len(stats.outcomes), 3) is.Equal(filepath.Base(stats.failed[0]), "b") }) @@ -75,6 +77,7 @@ func TestSync_Execute(t *testing.T) { is.Equal(len(stats.timedOut), 1) is.Equal(len(stats.failed), 0) is.Equal(len(stats.succeeded), 0) + is.Equal(len(stats.outcomes), 1) }) t.Run("respects ConNum throttle", func(t *testing.T) { @@ -125,7 +128,9 @@ func TestSync_Execute(t *testing.T) { is.Equal(len(stats.timedOut), 3) is.Equal(len(stats.failed), 0) is.Equal(len(stats.succeeded), 0) + is.Equal(len(stats.outcomes), 3) }) + } func TestSync_FilterRepos(t *testing.T) { @@ -221,3 +226,65 @@ func TestRunSummary_HasFailures(t *testing.T) { is.Equal(s.HasFailures(), false) }) } + +func TestDisplayName(t *testing.T) { + t.Run("two segments stripped to last two", func(t *testing.T) { + t.Parallel() + is := is.New(t) + is.Equal(displayName("/Users/x/go/src/github.com/dxe-ai/agent"), "dxe-ai/agent") + }) + + t.Run("single parent and leaf", func(t *testing.T) { + t.Parallel() + is := is.New(t) + is.Equal(displayName("/tmp/agent"), "tmp/agent") + }) + + t.Run("bare leaf returns leaf only", func(t *testing.T) { + t.Parallel() + is := is.New(t) + // On Unix, "/agent" cleans to "/agent"; parent becomes "/" → bare leaf. + is.Equal(displayName("/agent"), "agent") + }) + + t.Run("trailing slash is normalized", func(t *testing.T) { + t.Parallel() + is := is.New(t) + is.Equal(displayName("/a/b/c/"), "b/c") + }) + + t.Run("dot relative path returns just the leaf", func(t *testing.T) { + t.Parallel() + is := is.New(t) + is.Equal(displayName("./agent"), "agent") + }) + + t.Run("windows drive root parent returns just the leaf", func(t *testing.T) { + t.Parallel() + is := is.New(t) + // On non-Windows this exercises the helper directly; on Windows + // `filepath.Clean("C:\\repo")` produces a parent of "C:\" that + // trims to "C:" — which `isDriveRoot` recognises as no + // meaningful parent. + is.Equal(isDriveRoot("C:"), true) + is.Equal(isDriveRoot("z:"), true) + is.Equal(isDriveRoot("C:\\"), false) + is.Equal(isDriveRoot("CC"), false) + is.Equal(isDriveRoot("12"), false) + }) +} + +func TestSync_Execute_FailureFallsBackToErrText(t *testing.T) { + t.Parallel() + is := is.New(t) + + // Simulate a Git invocation that fails before the child process can + // write anything to stderr — e.g. the repository directory is missing. + s := newSyncWithFake(func(_ context.Context, _, _ string, _ ...string) (string, string, error) { + return "", "", errors.New("chdir failed: no such file or directory") + }, 1) + + stats := s.execute(context.Background(), []string{"a"}, time.Second) + is.Equal(len(stats.outcomes), 1) + is.Equal(stats.outcomes[0].Message, "chdir failed: no such file or directory") +}