esync

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

syncer.go (12940B)


      1 // Package syncer builds and executes rsync commands based on esync
      2 // configuration, handling local and remote (SSH) destinations.
      3 package syncer
      4 
      5 import (
      6 	"bufio"
      7 	"context"
      8 	"fmt"
      9 	"os"
     10 	"os/exec"
     11 	"path/filepath"
     12 	"regexp"
     13 	"strconv"
     14 	"strings"
     15 	"time"
     16 
     17 	"github.com/louloulibs/esync/internal/config"
     18 )
     19 
     20 // ---------------------------------------------------------------------------
     21 // Types
     22 // ---------------------------------------------------------------------------
     23 
     24 // ProgressFunc is called for each line of rsync output during RunWithProgress.
     25 type ProgressFunc func(line string)
     26 
     27 // FileEntry records a transferred file and its size in bytes.
     28 type FileEntry struct {
     29 	Name  string
     30 	Bytes int64
     31 }
     32 
     33 // Result captures the outcome of a sync operation.
     34 type Result struct {
     35 	Success      bool
     36 	FilesCount   int
     37 	BytesTotal   int64
     38 	Duration     time.Duration
     39 	Files        []FileEntry
     40 	ErrorMessage string
     41 }
     42 
     43 // Syncer builds and executes rsync commands from a config.Config.
     44 type Syncer struct {
     45 	cfg    *config.Config
     46 	DryRun bool
     47 }
     48 
     49 // ---------------------------------------------------------------------------
     50 // Constructor
     51 // ---------------------------------------------------------------------------
     52 
     53 // New returns a Syncer configured from the given Config.
     54 func New(cfg *config.Config) *Syncer {
     55 	return &Syncer{cfg: cfg}
     56 }
     57 
     58 // ---------------------------------------------------------------------------
     59 // Public methods
     60 // ---------------------------------------------------------------------------
     61 
     62 // rsyncBin returns the path to the rsync binary, preferring a homebrew
     63 // install over the macOS system openrsync (which lacks --info=progress2).
     64 func rsyncBin() string {
     65 	candidates := []string{
     66 		"/opt/homebrew/bin/rsync",
     67 		"/usr/local/bin/rsync",
     68 	}
     69 	for _, c := range candidates {
     70 		if _, err := os.Stat(c); err == nil {
     71 			return c
     72 		}
     73 	}
     74 	return "rsync" // fallback to PATH
     75 }
     76 
     77 // minRsyncVersion is the minimum rsync version required for --info=progress2.
     78 const minRsyncVersion = "3.1.0"
     79 
     80 // CheckRsync verifies that rsync is installed and returns its version string.
     81 // Returns an error if rsync is not found on PATH or if the version is too old
     82 // (--info=progress2 requires rsync >= 3.1.0).
     83 func CheckRsync() (string, error) {
     84 	out, err := exec.Command(rsyncBin(), "--version").Output()
     85 	if err != nil {
     86 		return "", fmt.Errorf("rsync not found: %w\nInstall rsync 3.1+ (e.g. brew install rsync, apt install rsync) and try again", err)
     87 	}
     88 	// First line is "rsync  version X.Y.Z  protocol version N"
     89 	firstLine := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
     90 
     91 	// Extract version number
     92 	if m := reRsyncVersion.FindStringSubmatch(firstLine); len(m) > 1 {
     93 		if compareVersions(m[1], minRsyncVersion) < 0 {
     94 			return firstLine, fmt.Errorf("rsync %s is too old (need %s+); install a newer rsync (e.g. brew install rsync)", m[1], minRsyncVersion)
     95 		}
     96 	}
     97 
     98 	return firstLine, nil
     99 }
    100 
    101 // reRsyncVersion extracts the version number from rsync --version output.
    102 var reRsyncVersion = regexp.MustCompile(`version\s+(\d+\.\d+\.\d+)`)
    103 
    104 // compareVersions compares two dotted version strings (e.g. "3.1.0" vs "2.6.9").
    105 // Returns -1, 0, or 1.
    106 func compareVersions(a, b string) int {
    107 	pa := strings.Split(a, ".")
    108 	pb := strings.Split(b, ".")
    109 	for i := 0; i < len(pa) && i < len(pb); i++ {
    110 		na, _ := strconv.Atoi(pa[i])
    111 		nb, _ := strconv.Atoi(pb[i])
    112 		if na < nb {
    113 			return -1
    114 		}
    115 		if na > nb {
    116 			return 1
    117 		}
    118 	}
    119 	return len(pa) - len(pb)
    120 }
    121 
    122 // BuildCommand constructs the rsync argument list with all flags, excludes,
    123 // SSH options, extra_args, source (trailing /), and destination.
    124 func (s *Syncer) BuildCommand() []string {
    125 	args := []string{rsyncBin(), "--recursive", "--times", "--progress", "--stats", "--info=progress2"}
    126 
    127 	rsync := s.cfg.Settings.Rsync
    128 
    129 	// Symlink handling: --copy-links dereferences all symlinks,
    130 	// --copy-unsafe-links only dereferences symlinks pointing outside the tree.
    131 	if rsync.CopyLinks {
    132 		args = append(args, "--copy-links")
    133 	} else {
    134 		args = append(args, "--copy-unsafe-links")
    135 	}
    136 
    137 	// Conditional flags
    138 	if rsync.Archive {
    139 		args = append(args, "--archive")
    140 	}
    141 	if rsync.Compress {
    142 		args = append(args, "--compress")
    143 	}
    144 	if rsync.Delete {
    145 		args = append(args, "--delete")
    146 	}
    147 	if rsync.Backup {
    148 		args = append(args, "--backup")
    149 		if rsync.BackupDir != "" {
    150 			args = append(args, "--backup-dir="+rsync.BackupDir)
    151 		}
    152 	}
    153 	if s.DryRun {
    154 		args = append(args, "--dry-run")
    155 	}
    156 
    157 	// Include/exclude filter rules
    158 	if len(s.cfg.Settings.Include) > 0 {
    159 		// Emit include rules: ancestor dirs + subtree for each prefix
    160 		seen := make(map[string]bool)
    161 		for _, inc := range s.cfg.Settings.Include {
    162 			inc = filepath.Clean(inc)
    163 			// Add ancestor directories (e.g. "docs/api" needs "docs/")
    164 			parts := strings.Split(inc, string(filepath.Separator))
    165 			for i := 1; i < len(parts); i++ {
    166 				ancestor := strings.Join(parts[:i], "/") + "/"
    167 				if !seen[ancestor] {
    168 					args = append(args, "--include="+ancestor)
    169 					seen[ancestor] = true
    170 				}
    171 			}
    172 			// Include as both file and directory (we don't know which it is)
    173 			args = append(args, "--include="+inc)
    174 			args = append(args, "--include="+inc+"/")
    175 			args = append(args, "--include="+inc+"/**")
    176 		}
    177 
    178 		// Exclude patterns from ignore lists (applied within included paths)
    179 		for _, pattern := range s.cfg.AllIgnorePatterns() {
    180 			cleaned := strings.TrimPrefix(pattern, "**/")
    181 			args = append(args, "--exclude="+cleaned)
    182 		}
    183 
    184 		// Catch-all exclude: block everything not explicitly included
    185 		args = append(args, "--exclude=*")
    186 	} else {
    187 		// No include filter — just exclude patterns as before
    188 		for _, pattern := range s.cfg.AllIgnorePatterns() {
    189 			cleaned := strings.TrimPrefix(pattern, "**/")
    190 			args = append(args, "--exclude="+cleaned)
    191 		}
    192 	}
    193 
    194 	// Extra args passthrough
    195 	args = append(args, rsync.ExtraArgs...)
    196 
    197 	// SSH transport
    198 	if sshCmd := s.buildSSHCommand(); sshCmd != "" {
    199 		args = append(args, "-e", sshCmd)
    200 	}
    201 
    202 	// Source (must end with /)
    203 	source := s.cfg.Sync.Local
    204 	if !strings.HasSuffix(source, "/") {
    205 		source += "/"
    206 	}
    207 	args = append(args, source)
    208 
    209 	// Destination
    210 	args = append(args, s.buildDestination())
    211 
    212 	return args
    213 }
    214 
    215 // Run executes the rsync command, captures output, and parses stats.
    216 func (s *Syncer) Run() (*Result, error) {
    217 	return s.RunContext(context.Background())
    218 }
    219 
    220 // RunContext executes the rsync command with a context for cancellation.
    221 func (s *Syncer) RunContext(ctx context.Context) (*Result, error) {
    222 	args := s.BuildCommand()
    223 
    224 	start := time.Now()
    225 
    226 	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
    227 	output, err := cmd.CombinedOutput()
    228 	duration := time.Since(start)
    229 
    230 	outStr := string(output)
    231 
    232 	result := &Result{
    233 		Duration: duration,
    234 		Files:    s.extractFiles(outStr),
    235 	}
    236 
    237 	count, bytes := s.extractStats(outStr)
    238 	result.FilesCount = count
    239 	result.BytesTotal = bytes
    240 
    241 	if err != nil {
    242 		result.Success = false
    243 		result.ErrorMessage = fmt.Sprintf("rsync error: %v\n%s", err, outStr)
    244 		return result, err
    245 	}
    246 
    247 	result.Success = true
    248 	return result, nil
    249 }
    250 
    251 // RunWithProgress executes rsync while streaming each output line to onLine.
    252 // The context allows cancellation (e.g. when the TUI exits).
    253 // If onLine is nil it falls through to RunContext().
    254 func (s *Syncer) RunWithProgress(ctx context.Context, onLine ProgressFunc) (*Result, error) {
    255 	if onLine == nil {
    256 		return s.RunContext(ctx)
    257 	}
    258 
    259 	args := s.BuildCommand()
    260 	start := time.Now()
    261 
    262 	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
    263 
    264 	pr, pw, err := os.Pipe()
    265 	if err != nil {
    266 		return nil, fmt.Errorf("creating pipe: %w", err)
    267 	}
    268 	cmd.Stdout = pw
    269 	cmd.Stderr = pw
    270 
    271 	if err := cmd.Start(); err != nil {
    272 		pw.Close()
    273 		pr.Close()
    274 		return nil, fmt.Errorf("starting rsync: %w", err)
    275 	}
    276 	pw.Close() // parent closes write end so scanner sees EOF
    277 
    278 	var buf strings.Builder
    279 	scanner := bufio.NewScanner(pr)
    280 	for scanner.Scan() {
    281 		line := scanner.Text()
    282 		buf.WriteString(line + "\n")
    283 		onLine(line)
    284 	}
    285 	pr.Close()
    286 
    287 	waitErr := cmd.Wait()
    288 	duration := time.Since(start)
    289 	outStr := buf.String()
    290 
    291 	result := &Result{
    292 		Duration: duration,
    293 		Files:    s.extractFiles(outStr),
    294 	}
    295 	count, bytes := s.extractStats(outStr)
    296 	result.FilesCount = count
    297 	result.BytesTotal = bytes
    298 
    299 	if waitErr != nil {
    300 		result.Success = false
    301 		result.ErrorMessage = fmt.Sprintf("rsync error: %v\n%s", waitErr, outStr)
    302 		return result, waitErr
    303 	}
    304 
    305 	result.Success = true
    306 	return result, nil
    307 }
    308 
    309 // ---------------------------------------------------------------------------
    310 // Private helpers
    311 // ---------------------------------------------------------------------------
    312 
    313 // buildSSHCommand builds the SSH command string with port, identity file,
    314 // and ControlMaster keepalive options. Returns empty string if no SSH config.
    315 func (s *Syncer) buildSSHCommand() string {
    316 	ssh := s.cfg.Sync.SSH
    317 	if ssh == nil || ssh.Host == "" {
    318 		return ""
    319 	}
    320 
    321 	parts := []string{"ssh"}
    322 
    323 	if ssh.Port != 0 {
    324 		parts = append(parts, fmt.Sprintf("-p %d", ssh.Port))
    325 	}
    326 
    327 	if ssh.IdentityFile != "" {
    328 		parts = append(parts, fmt.Sprintf("-i %s", ssh.IdentityFile))
    329 	}
    330 
    331 	// ControlMaster keepalive options
    332 	parts = append(parts,
    333 		"-o ControlMaster=auto",
    334 		"-o ControlPath=/tmp/esync-ssh-%r@%h:%p",
    335 		"-o ControlPersist=600",
    336 	)
    337 
    338 	return strings.Join(parts, " ")
    339 }
    340 
    341 // buildDestination builds the destination string from SSH config or the raw
    342 // remote string. When SSH config is present, it constructs user@host:path.
    343 func (s *Syncer) buildDestination() string {
    344 	ssh := s.cfg.Sync.SSH
    345 	if ssh == nil || ssh.Host == "" {
    346 		return s.cfg.Sync.Remote
    347 	}
    348 
    349 	remote := s.cfg.Sync.Remote
    350 	if ssh.User != "" {
    351 		return fmt.Sprintf("%s@%s:%s", ssh.User, ssh.Host, remote)
    352 	}
    353 	return fmt.Sprintf("%s:%s", ssh.Host, remote)
    354 }
    355 
    356 // reProgressSize matches the final size in a progress line, e.g.
    357 // "  8772 100%  61.99MB/s  00:00:00 (xfer#1, to-check=2/4)"
    358 var reProgressSize = regexp.MustCompile(`^\s*([\d,]+)\s+100%`)
    359 
    360 // extractFiles extracts transferred file names and per-file sizes from
    361 // rsync --progress output. Each filename line is followed by one or more
    362 // progress lines; the final one (with "100%") contains the file size.
    363 func (s *Syncer) extractFiles(output string) []FileEntry {
    364 	var files []FileEntry
    365 	lines := strings.Split(output, "\n")
    366 
    367 	var pending string // last seen filename awaiting a size
    368 
    369 	for _, line := range lines {
    370 		line = strings.TrimRight(line, "\r")
    371 		trimmed := strings.TrimSpace(line)
    372 
    373 		if trimmed == "" || trimmed == "sending incremental file list" {
    374 			continue
    375 		}
    376 
    377 		// Stop at stats section
    378 		if strings.HasPrefix(trimmed, "Number of") ||
    379 			strings.HasPrefix(trimmed, "sent ") ||
    380 			strings.HasPrefix(trimmed, "total size") {
    381 			break
    382 		}
    383 
    384 		// Skip directory entries
    385 		if strings.HasSuffix(trimmed, "/") || trimmed == "." || trimmed == "./" {
    386 			continue
    387 		}
    388 
    389 		// Check if this is a per-file 100% progress line (extract size).
    390 		// Must come before the progress2 guard since both contain xfr#/to-chk=.
    391 		if m := reProgressSize.FindStringSubmatch(trimmed); len(m) > 1 && pending != "" {
    392 			cleaned := strings.ReplaceAll(m[1], ",", "")
    393 			size, _ := strconv.ParseInt(cleaned, 10, 64)
    394 			files = append(files, FileEntry{Name: pending, Bytes: size})
    395 			pending = ""
    396 			continue
    397 		}
    398 
    399 		// Skip --info=progress2 summary lines (partial %, e.g. "1,234  56%  1.23MB/s  0:00:01 (xfr#1, to-chk=2/4)")
    400 		if strings.Contains(trimmed, "xfr#") || strings.Contains(trimmed, "to-chk=") {
    401 			continue
    402 		}
    403 
    404 		// Skip other progress lines (partial %, bytes/sec)
    405 		if strings.Contains(trimmed, "%") || strings.Contains(trimmed, "bytes/sec") {
    406 			continue
    407 		}
    408 
    409 		// Flush any pending file without a matched size
    410 		if pending != "" {
    411 			files = append(files, FileEntry{Name: pending})
    412 			pending = ""
    413 		}
    414 
    415 		// This looks like a filename
    416 		pending = trimmed
    417 	}
    418 
    419 	// Flush last pending
    420 	if pending != "" {
    421 		files = append(files, FileEntry{Name: pending})
    422 	}
    423 
    424 	return files
    425 }
    426 
    427 // extractStats extracts the file count and total bytes from rsync output.
    428 // It looks for "Number of regular files transferred: N" and
    429 // "Total file size: N bytes" patterns.
    430 func (s *Syncer) extractStats(output string) (int, int64) {
    431 	var count int
    432 	var totalBytes int64
    433 
    434 	// Match "Number of regular files transferred: 3" or "Number of files transferred: 2"
    435 	reCount := regexp.MustCompile(`Number of (?:regular )?files transferred:\s*([\d,]+)`)
    436 	if m := reCount.FindStringSubmatch(output); len(m) > 1 {
    437 		cleaned := strings.ReplaceAll(m[1], ",", "")
    438 		if n, err := strconv.Atoi(cleaned); err == nil {
    439 			count = n
    440 		}
    441 	}
    442 
    443 	// Match "Total transferred file size: 5,678 bytes" (actual bytes sent,
    444 	// not the total source tree size reported by "Total file size:")
    445 	reBytes := regexp.MustCompile(`Total transferred file size:\s*([\d,]+)`)
    446 	if m := reBytes.FindStringSubmatch(output); len(m) > 1 {
    447 		cleaned := strings.ReplaceAll(m[1], ",", "")
    448 		if n, err := strconv.ParseInt(cleaned, 10, 64); err == nil {
    449 			totalBytes = n
    450 		}
    451 	}
    452 
    453 	return count, totalBytes
    454 }