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 }