# Show per-side throughput at end of run and via --progress ticker

`f3f7b3a`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

Soph·2mo ago·8 files·+437 added/-0 removed

Two new outputs surfacing the per-side counters from the prior commit:

- statsLines now appends a 'throughput: source=X @ Y/s · target=A @ B/s' line whenever --stats produced non-zero per-side bytes. Computed against the stats collector's own wall-clock window so it works without --measure-memory.

- A new --progress flag wires a goroutine-driven stderr renderer that refreshes the same per-side line every 200ms in place. Auto-suppressed when stderr isn't a TTY so JSON pipes and CI logs stay clean. The reporter is started in newSession and torn down via a deferred syncSession.finish() in each public entry point so the final frame reflects the closing byte counts before the newline is emitted.

Plumbed through unstable.AdvancedOptions.Progress and exposed on sync, replicate, plan, bootstrap, and fetch.

## Sessions

## Changes

8

- cmd/git-sync

- Mbootstrap.go+1

- Mfetch.go+1

- Msyncplan.go+1

- internal/syncer

- Aprogress.go+146

- Aprogress_test.go+119

- Msyncer.go+65

- Athroughput_test.go+99

- unstable

- Mclient.go+5

`75 unmodified lines`

cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "mirror tags")
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
cmd.Flags().BoolVar(&req.Options.Progress, "progress", false, "show live per-side throughput on stderr (TTY only)")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")
cmd.Flags().Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
cmd.Flags().Int64Var(&req.Options.TargetMaxPackBytes, "target-max-pack-bytes", 0, "target receive-pack body size limit; batches are planned and auto-subdivided to fit")

`Mcmd/git-sync/bootstrap.go+1`

`71 unmodified lines`

addProtocolFlag(cmd, &protocolVal)
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
cmd.Flags().BoolVar(&req.Options.Progress, "progress", false, "show live per-side throughput on stderr (TTY only)")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")
cmd.Flags().StringArrayVar(&haveRefs, "have-ref", nil, "source ref name to advertise as have; short names map to branches")
cmd.Flags().StringArrayVar(&haveHashesRaw, "have", nil, "explicit object hash to advertise as have")

`Mcmd/git-sync/fetch.go+1`

`112 unmodified lines`

cmd.Flags().BoolVar(&req.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
cmd.Flags().BoolVar(&req.Options.Progress, "progress", false, "show live per-side throughput on stderr (TTY only)")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")
cmd.Flags().IntVar(&req.Options.MaterializedMaxObjects, "materialized-max-objects", unstable.DefaultMaterializedMaxObjects, "abort non-relay materialized syncs above this many objects")
cmd.Flags().Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap-relay push if the streamed source pack exceeds this many bytes")

`Mcmd/git-sync/syncplan.go+1`

`1-146 unmodified lines`

package syncer

import (
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
	"sync"
	"time"
)

// progressReporter renders live per-side throughput to a writer (typically
// os.Stderr) by sampling the statsCollector's atomic byte counters on a
// fixed interval. It is intentionally a one-line in-place renderer so it
// stays out of the way of the final command output.
type progressReporter struct {
	out      io.Writer
	stats    *statsCollector
	interval time.Duration
	start    time.Time

stopOnce sync.Once
	stop     chan struct{}
	done     chan struct{}

mu      sync.Mutex
	lastLen int
}

func newProgressReporter(out io.Writer, stats *statsCollector, interval time.Duration) *progressReporter {
	if interval <= 0 {
		interval = 200 * time.Millisecond
	}
	return &progressReporter{
		out:      out,
		stats:    stats,
		interval: interval,
		start:    time.Now(),
		stop:     make(chan struct{}),
		done:     make(chan struct{}),
	}
}

// run drives the ticker until stop() is called. Safe to call once.
func (p *progressReporter) run() {
	defer close(p.done)
	ticker := time.NewTicker(p.interval)
	defer ticker.Stop()
	for {
		select {
		case <-p.stop:
			return
		case <-ticker.C:
			p.render(false)
		}
	}
}

// terminate halts the ticker, draws one final frame so the printed line
// reflects the closing byte counts, and emits a newline so subsequent
// command output starts on a fresh row.
func (p *progressReporter) terminate() {
	p.stopOnce.Do(func() {
		close(p.stop)
		<-p.done
		p.render(true)
		p.mu.Lock()
		if p.lastLen > 0 {
			fmt.Fprintln(p.out)
		}
		p.mu.Unlock()
	})
}

func (p *progressReporter) render(final bool) {
	sides := p.stats.liveSides()
	if len(sides) == 0 && !final {
		return
	}
	sort.Slice(sides, func(i, j int) bool { return sides[i].Label < sides[j].Label })
	elapsed := time.Since(p.start)

var b strings.Builder
	for i, side := range sides {
		if i > 0 {
			b.WriteString(" · ")
		}
		fmt.Fprintf(&b, "%s: %s @ %s",
			side.Label, formatBytes(side.Bytes), formatRate(side.Bytes, elapsed))
	}
	line := b.String()

p.mu.Lock()
	defer p.mu.Unlock()
	pad := 0
	if p.lastLen > len(line) {
		pad = p.lastLen - len(line)
	}
	fmt.Fprint(p.out, "\r"+line+strings.Repeat(" ", pad))
	p.lastLen = len(line)
}

// stderrIsTTY reports whether stderr is attached to a terminal. The
// progress ticker is suppressed otherwise because '\r' updates only make
// sense on a TTY and would otherwise spam log files.
func stderrIsTTY() bool {
	fi, err := os.Stderr.Stat()
	if err != nil {
		return false
	}
	return (fi.Mode() & os.ModeCharDevice) != 0
}

// formatBytes renders byte counts in IEC-ish human units (binary base).
func formatBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	ddiv, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		ddiv *= unit
		exp++
	}
	value := float64(n) / float64(ddiv)
	suffix := []string{"KB", "MB", "GB", "TB", "PB"}[exp]
	if value >= 100 {
		return fmt.Sprintf("%.0f %s", value, suffix)
	}
	if value >= 10 {
		return fmt.Sprintf("%.1f %s", value, suffix)
	}
	return fmt.Sprintf("%.2f %s", value, suffix)
}

// formatRate renders a bytes/second average over the supplied duration.
// Returns "0 B/s" until the duration is large enough to be meaningful,
// avoiding misleadingly large rates from sub-millisecond samples.
func formatRate(bytes int64, dur time.Duration) string {
	if dur < 50*time.Millisecond || bytes <= 0 {
		return "0 B/s"
	}
	rate := float64(bytes) / dur.Seconds()
	return formatBytes(int64(rate)) + "/s"
}
