From 28d25902197f4502eeb4ab6bf8e689239280a12c Mon Sep 17 00:00:00 2001 From: Efrem Date: Sat, 20 Dec 2025 20:32:53 -0800 Subject: [PATCH] Add --log-progress flag for log-friendly progress output Adds a new --log-progress (-lp) flag to the cp command that outputs progress information in a log-friendly format suitable for pipes, log files, and non-TTY environments. Key features: - Outputs to stderr with newlines (not carriage returns) - No ANSI color codes or terminal buffer manipulation - Updates every 2 seconds to avoid log spam - Shows: percentage, bytes transferred/total, speed, ETA, and file count - Format: "Progress: X% - Y/Z @ speed - ETA (N/M files)" This addresses the need for progress reporting in CI/CD pipelines, log files, and other non-interactive environments where the existing --show-progress flag doesn't work. Fixes #768 Fixes #753 --- command/cp.go | 10 ++- progressbar/progressbar.go | 129 +++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/command/cp.go b/command/cp.go index 7ed06bfee..d2588090a 100644 --- a/command/cp.go +++ b/command/cp.go @@ -257,6 +257,11 @@ func NewCopyCommandFlags() []cli.Flag { Aliases: []string{"sp"}, Usage: "show a progress bar", }, + &cli.BoolFlag{ + Name: "log-progress", + Aliases: []string{"lp"}, + Usage: "show progress in log-friendly format (works in pipes and non-TTY)", + }, } sharedFlags := NewSharedFlags() return append(copyFlags, sharedFlags...) @@ -358,7 +363,10 @@ func NewCopy(c *cli.Context, deleteSource bool) (*Copy, error) { var commandProgressBar progressbar.ProgressBar - if c.Bool("show-progress") && !(src.Type == dst.Type) { + // Determine which progress bar to use + if c.Bool("log-progress") && !(src.Type == dst.Type) { + commandProgressBar = progressbar.NewLogProgressBar() + } else if c.Bool("show-progress") && !(src.Type == dst.Type) { commandProgressBar = progressbar.New() } else { commandProgressBar = &progressbar.NoOp{} diff --git a/progressbar/progressbar.go b/progressbar/progressbar.go index d8752d47d..0147c62d7 100644 --- a/progressbar/progressbar.go +++ b/progressbar/progressbar.go @@ -2,7 +2,9 @@ package progressbar import ( "fmt" + "os" "sync/atomic" + "time" "github.com/cheggaaa/pb/v3" ) @@ -76,3 +78,130 @@ func (cp *CommandProgressBar) AddCompletedBytes(bytes int64) { func (cp *CommandProgressBar) AddTotalBytes(bytes int64) { cp.progressbar.AddTotal(bytes) } + +// LogProgressBar outputs progress to stderr in a log-friendly format +// (newline-separated, no ANSI codes, works in pipes and non-TTY environments) +type LogProgressBar struct { + totalObjects int64 + completedObjects int64 + totalBytes int64 + completedBytes int64 + startTime time.Time + lastUpdate time.Time + updateInterval time.Duration +} + +var _ ProgressBar = (*LogProgressBar)(nil) + +func NewLogProgressBar() *LogProgressBar { + return &LogProgressBar{ + updateInterval: 2 * time.Second, // Update every 2 seconds + } +} + +func (lp *LogProgressBar) Start() { + lp.startTime = time.Now() + lp.lastUpdate = lp.startTime +} + +func (lp *LogProgressBar) Finish() { + lp.print() // Print final status + fmt.Fprintln(os.Stderr, "Transfer complete") +} + +func (lp *LogProgressBar) IncrementCompletedObjects() { + atomic.AddInt64(&lp.completedObjects, 1) + lp.maybeUpdate() +} + +func (lp *LogProgressBar) IncrementTotalObjects() { + atomic.AddInt64(&lp.totalObjects, 1) +} + +func (lp *LogProgressBar) AddCompletedBytes(bytes int64) { + atomic.AddInt64(&lp.completedBytes, bytes) + lp.maybeUpdate() +} + +func (lp *LogProgressBar) AddTotalBytes(bytes int64) { + atomic.AddInt64(&lp.totalBytes, bytes) +} + +func (lp *LogProgressBar) maybeUpdate() { + now := time.Now() + if now.Sub(lp.lastUpdate) >= lp.updateInterval { + lp.lastUpdate = now + lp.print() + } +} + +func (lp *LogProgressBar) print() { + completed := atomic.LoadInt64(&lp.completedBytes) + total := atomic.LoadInt64(&lp.totalBytes) + completedObjs := atomic.LoadInt64(&lp.completedObjects) + totalObjs := atomic.LoadInt64(&lp.totalObjects) + + if total == 0 { + return // Nothing to report yet + } + + percent := float64(completed) / float64(total) * 100 + + // Calculate speed and ETA + elapsed := time.Since(lp.startTime).Seconds() + var speed float64 + var eta string + + if elapsed > 0 { + speed = float64(completed) / elapsed + if speed > 0 { + remaining := float64(total-completed) / speed + eta = formatDuration(time.Duration(remaining) * time.Second) + } else { + eta = "unknown" + } + } else { + eta = "calculating..." + } + + // Format output: Progress: X% - Y/Z @ speed - ETA (N/M files) + fmt.Fprintf(os.Stderr, "%.1f%% - %s/%s @ %s/s - ETA: %s (%d/%d files)\n", + percent, + formatBytes(completed), + formatBytes(total), + formatBytes(int64(speed)), + eta, + completedObjs, + totalObjs, + ) +} + +func formatBytes(bytes int64) string { + const unit = 1000 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "kMGTPE"[exp]) +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + + if h > 0 { + return fmt.Sprintf("%dh%dm%ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + return fmt.Sprintf("%ds", s) +}