esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

commit 9c6ea0f25c125d88586f684206b741db4e275ae3
parent 4c56b80bafe0999f2f14de8a78667ff0d3cdd825
Author: Erik Loualiche <[email protected]>
Date:   Sun,  1 Mar 2026 16:35:11 -0600

feat: stream rsync progress to TUI with real-time percentage

Replace CombinedOutput() with os.Pipe + bufio.Scanner in new
RunWithProgress(ctx, callback) method. Add --info=progress2 to rsync
flags for overall transfer percentage. Stream each output line to the
log view and parse progress for the header display ("Syncing 45%").

Also: prefer Homebrew rsync over macOS openrsync, validate rsync >= 3.1
on startup, and use context.Context to kill hanging rsync on TUI quit.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
MREADME.md | 6+++++-
Mcmd/sync.go | 36+++++++++++++++++++++++++++++++++++-
Minternal/syncer/syncer.go | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Minternal/syncer/syncer_test.go | 8++++----
Minternal/tui/dashboard.go | 16++++++++++------
5 files changed, 183 insertions(+), 20 deletions(-)

diff --git a/README.md b/README.md @@ -513,5 +513,9 @@ esync sync --dry-run ## System Requirements - **Go** 1.22+ (for building from source) -- **rsync** 3.x +- **rsync** 3.1+ (required for `--info=progress2` real-time transfer progress) - **macOS** or **Linux** (uses fsnotify for filesystem events) + +> **macOS note:** The built-in `/usr/bin/rsync` is Apple's `openrsync` which is too old. +> Install a modern rsync via Homebrew: `brew install rsync`. esync will automatically +> prefer the Homebrew version when available. diff --git a/cmd/sync.go b/cmd/sync.go @@ -1,10 +1,12 @@ package cmd import ( + "context" "fmt" "os" "os/signal" "path/filepath" + "regexp" "strings" "syscall" "time" @@ -54,6 +56,9 @@ func init() { rootCmd.AddCommand(syncCmd) } +// reProgress2 matches the percentage in rsync --info=progress2 output lines. +var reProgress2 = regexp.MustCompile(`(\d+)%`) + // --------------------------------------------------------------------------- // Config loading // --------------------------------------------------------------------------- @@ -156,14 +161,43 @@ func runSync(cmd *cobra.Command, args []string) error { // --------------------------------------------------------------------------- func runTUI(cfg *config.Config, s *syncer.Syncer) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote) syncCh := app.SyncEventChan() + logCh := app.LogEntryChan() + handler := func() { // Update header status to syncing syncCh <- tui.SyncEvent{Status: "status:syncing"} - result, err := s.Run() + var lastPct string + onLine := func(line string) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + // Stream to log view + select { + case logCh <- tui.LogEntry{Time: time.Now(), Level: "INF", Message: trimmed}: + default: + } + // Parse progress2 percentage and update header + if m := reProgress2.FindStringSubmatch(trimmed); len(m) > 1 { + pct := m[1] + if pct != lastPct { + lastPct = pct + select { + case syncCh <- tui.SyncEvent{Status: "status:syncing " + pct + "%"}: + default: + } + } + } + } + + result, err := s.RunWithProgress(ctx, onLine) now := time.Now() if err != nil { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go @@ -3,7 +3,10 @@ package syncer import ( + "bufio" + "context" "fmt" + "os" "os/exec" "regexp" "strconv" @@ -17,6 +20,9 @@ import ( // Types // --------------------------------------------------------------------------- +// ProgressFunc is called for each line of rsync output during RunWithProgress. +type ProgressFunc func(line string) + // FileEntry records a transferred file and its size in bytes. type FileEntry struct { Name string @@ -52,22 +58,70 @@ func New(cfg *config.Config) *Syncer { // Public methods // --------------------------------------------------------------------------- +// rsyncBin returns the path to the rsync binary, preferring a homebrew +// install over the macOS system openrsync (which lacks --info=progress2). +func rsyncBin() string { + candidates := []string{ + "/opt/homebrew/bin/rsync", + "/usr/local/bin/rsync", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "rsync" // fallback to PATH +} + +// minRsyncVersion is the minimum rsync version required for --info=progress2. +const minRsyncVersion = "3.1.0" + // CheckRsync verifies that rsync is installed and returns its version string. -// Returns an error if rsync is not found on PATH. +// Returns an error if rsync is not found on PATH or if the version is too old +// (--info=progress2 requires rsync >= 3.1.0). func CheckRsync() (string, error) { - out, err := exec.Command("rsync", "--version").Output() + out, err := exec.Command(rsyncBin(), "--version").Output() if err != nil { - return "", fmt.Errorf("rsync not found: %w\nInstall rsync (e.g. brew install rsync, apt install rsync) and try again", err) + return "", fmt.Errorf("rsync not found: %w\nInstall rsync 3.1+ (e.g. brew install rsync, apt install rsync) and try again", err) } // First line is "rsync version X.Y.Z protocol version N" - firstLine := strings.SplitN(string(out), "\n", 2)[0] - return strings.TrimSpace(firstLine), nil + firstLine := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) + + // Extract version number + if m := reRsyncVersion.FindStringSubmatch(firstLine); len(m) > 1 { + if compareVersions(m[1], minRsyncVersion) < 0 { + return firstLine, fmt.Errorf("rsync %s is too old (need %s+); install a newer rsync (e.g. brew install rsync)", m[1], minRsyncVersion) + } + } + + return firstLine, nil +} + +// reRsyncVersion extracts the version number from rsync --version output. +var reRsyncVersion = regexp.MustCompile(`version\s+(\d+\.\d+\.\d+)`) + +// compareVersions compares two dotted version strings (e.g. "3.1.0" vs "2.6.9"). +// Returns -1, 0, or 1. +func compareVersions(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + na, _ := strconv.Atoi(pa[i]) + nb, _ := strconv.Atoi(pb[i]) + if na < nb { + return -1 + } + if na > nb { + return 1 + } + } + return len(pa) - len(pb) } // BuildCommand constructs the rsync argument list with all flags, excludes, // SSH options, extra_args, source (trailing /), and destination. func (s *Syncer) BuildCommand() []string { - args := []string{"rsync", "--recursive", "--times", "--progress", "--stats"} + args := []string{rsyncBin(), "--recursive", "--times", "--progress", "--stats", "--info=progress2"} rsync := s.cfg.Settings.Rsync @@ -128,12 +182,16 @@ func (s *Syncer) BuildCommand() []string { // Run executes the rsync command, captures output, and parses stats. func (s *Syncer) Run() (*Result, error) { + return s.RunContext(context.Background()) +} + +// RunContext executes the rsync command with a context for cancellation. +func (s *Syncer) RunContext(ctx context.Context) (*Result, error) { args := s.BuildCommand() start := time.Now() - // args[0] is "rsync", the rest are arguments - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) output, err := cmd.CombinedOutput() duration := time.Since(start) @@ -158,6 +216,64 @@ func (s *Syncer) Run() (*Result, error) { return result, nil } +// RunWithProgress executes rsync while streaming each output line to onLine. +// The context allows cancellation (e.g. when the TUI exits). +// If onLine is nil it falls through to RunContext(). +func (s *Syncer) RunWithProgress(ctx context.Context, onLine ProgressFunc) (*Result, error) { + if onLine == nil { + return s.RunContext(ctx) + } + + args := s.BuildCommand() + start := time.Now() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + + pr, pw, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("creating pipe: %w", err) + } + cmd.Stdout = pw + cmd.Stderr = pw + + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + return nil, fmt.Errorf("starting rsync: %w", err) + } + pw.Close() // parent closes write end so scanner sees EOF + + var buf strings.Builder + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + line := scanner.Text() + buf.WriteString(line + "\n") + onLine(line) + } + pr.Close() + + waitErr := cmd.Wait() + duration := time.Since(start) + outStr := buf.String() + + result := &Result{ + Duration: duration, + Files: s.extractFiles(outStr), + } + count, bytes := s.extractStats(outStr) + result.FilesCount = count + result.BytesTotal = bytes + + if waitErr != nil { + result.Success = false + result.ErrorMessage = fmt.Sprintf("rsync error: %v\n%s", waitErr, outStr) + return result, waitErr + } + + result.Success = true + return result, nil +} + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -226,6 +342,11 @@ func (s *Syncer) extractFiles(output string) []FileEntry { continue } + // Skip --info=progress2 summary lines (e.g. " 1,234 56% 1.23MB/s 0:00:01 (xfr#1, to-chk=2/4)") + if strings.Contains(trimmed, "xfr#") || strings.Contains(trimmed, "to-chk=") { + continue + } + // Stop at stats section if strings.HasPrefix(trimmed, "Number of") || strings.HasPrefix(trimmed, "sent ") || diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go @@ -35,13 +35,13 @@ func TestBuildCommand_Local(t *testing.T) { s := New(cfg) cmd := s.BuildCommand() - // Should start with rsync - if cmd[0] != "rsync" { - t.Errorf("cmd[0] = %q, want %q", cmd[0], "rsync") + // Should start with rsync (possibly absolute path) + if !strings.HasSuffix(cmd[0], "rsync") { + t.Errorf("cmd[0] = %q, want rsync binary", cmd[0]) } // Must contain base flags - for _, flag := range []string{"--recursive", "--times", "--progress", "--copy-unsafe-links"} { + for _, flag := range []string{"--recursive", "--times", "--progress", "--info=progress2", "--copy-unsafe-links"} { if !containsArg(cmd, flag) { t.Errorf("missing flag %q in %v", flag, cmd) } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go @@ -233,14 +233,18 @@ func (m DashboardModel) View() string { // statusDisplay returns the icon and styled text for the current status. func (m DashboardModel) statusDisplay() (string, string) { - switch m.status { - case "watching": + switch { + case m.status == "watching": return statusSynced.Render("●"), statusSynced.Render("Watching") - case "syncing": - return statusSyncing.Render("⟳"), statusSyncing.Render("Syncing") - case "paused": + case strings.HasPrefix(m.status, "syncing"): + label := "Syncing" + if pct := strings.TrimPrefix(m.status, "syncing "); pct != m.status { + label = "Syncing " + pct + } + return statusSyncing.Render("⟳"), statusSyncing.Render(label) + case m.status == "paused": return dimStyle.Render("⏸"), dimStyle.Render("Paused") - case "error": + case m.status == "error": return statusError.Render("✗"), statusError.Render("Error") default: return "?", m.status