esync

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

commit 2639d4d30fda9cf3270e836a20d6b45569aea1a3
parent cf983b9e35e4553727630dcd13af1a8ba0d8b076
Author: Erik Loualiche <eloualiche@users.noreply.github.com>
Date:   Sun,  1 Mar 2026 15:52:00 -0600

fix: rsync output parsing, add delete/copy_links options, rsync check (#6)

- Fix extractFiles to not depend on "sending incremental file list"
  header (missing in rsync 3.4.x when captured programmatically)
- Fix extractStats regex to match both "Number of regular files
  transferred:" (rsync ≤3.3) and "Number of files transferred:"
  (rsync 3.4+)
- Add --stats flag to rsync for reliable stats output
- Parse per-file sizes from rsync progress output instead of showing
  total bytes for every file in the TUI
- Add Chmod to watcher relevant ops so touch(1) triggers sync
- Add rsync dependency check on startup with install hints
- Add settings.rsync.delete option (--delete)
- Add settings.rsync.copy_links option (--copy-links vs
  --copy-unsafe-links)
- Add VHS demo tape for README GIF generation

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
Mcmd/root.go | 9+++++++++
Mcmd/sync.go | 4++--
Ademo.tape | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mesync.toml.example | 10++++++++++
Minternal/config/config.go | 6++++++
Minternal/syncer/syncer.go | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Minternal/syncer/syncer_test.go | 4++--
Minternal/watcher/watcher.go | 4+++-
8 files changed, 221 insertions(+), 31 deletions(-)

diff --git a/cmd/root.go b/cmd/root.go @@ -5,6 +5,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/louloulibs/esync/internal/syncer" ) var cfgFile string @@ -13,6 +15,13 @@ var rootCmd = &cobra.Command{ Use: "esync", Short: "File synchronization tool using rsync", Long: "A file sync tool that watches for changes and automatically syncs them to a remote destination using rsync.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + _, err := syncer.CheckRsync() + if err != nil { + return err + } + return nil + }, } func Execute() { diff --git a/cmd/sync.go b/cmd/sync.go @@ -181,8 +181,8 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { // Send individual file events for _, f := range result.Files { syncCh <- tui.SyncEvent{ - File: f, - Size: formatSize(result.BytesTotal), + File: f.Name, + Size: formatSize(f.Bytes), Duration: result.Duration, Status: "synced", Time: now, diff --git a/demo.tape b/demo.tape @@ -0,0 +1,113 @@ +# ───────────────────────────────────────────────────────────────────────────── +# esync demo — watch local files and sync them with rsync, shown in a live TUI +# +# Usage: vhs demo.tape +# Requires: go, rsync, vhs (https://github.com/charmbracelet/vhs) +# ───────────────────────────────────────────────────────────────────────────── + +Output demo.gif +Require rsync + +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 600 +Set TypingSpeed 50ms +Set Theme "Catppuccin Mocha" +Set Padding 20 +Set WindowBar Colorful +Set WindowBarSize 40 + +# ── Hidden setup ──────────────────────────────────────────────────────────── +Hide + +# Build esync from the current repo +Type "go build -o /tmp/esync-bin/esync . 2>/dev/null" +Enter +Sleep 3s +Type "export PATH=/tmp/esync-bin:$PATH" +Enter +Sleep 500ms + +# Clean previous runs +Type "rm -rf /tmp/esync-demo /tmp/esync" +Enter +Sleep 500ms + +# Create a demo project with source files from this repo +Type "mkdir -p /tmp/esync-demo/project/{cmd,internal/{tui,config,syncer,watcher}}" +Enter +Sleep 300ms +Type "cp main.go go.mod go.sum README.md /tmp/esync-demo/project/" +Enter +Sleep 300ms +Type "cp cmd/*.go /tmp/esync-demo/project/cmd/" +Enter +Sleep 300ms +Type "cp internal/tui/*.go /tmp/esync-demo/project/internal/tui/" +Enter +Sleep 300ms +Type "cp internal/config/*.go /tmp/esync-demo/project/internal/config/" +Enter +Sleep 300ms +Type "cp internal/syncer/*.go /tmp/esync-demo/project/internal/syncer/" +Enter +Sleep 300ms +Type "cp internal/watcher/*.go /tmp/esync-demo/project/internal/watcher/" +Enter +Sleep 300ms + +# Write a minimal config file +Type `cat > /tmp/esync-demo/project/esync.toml << 'EOF' +[sync] +local = "." +remote = "/tmp/esync" + +[settings] +ignore = [".git", "node_modules", ".DS_Store", "build"] + +[settings.rsync] +archive = true +compress = false +EOF` +Enter +Sleep 500ms + +Type "cd /tmp/esync-demo/project" +Enter +Sleep 300ms + +Show + +# ── Demo ──────────────────────────────────────────────────────────────────── + +# 1. Show the project +Type "ls" +Enter +Sleep 2s + +# 2. Show the config file +Type "cat esync.toml" +Enter +Sleep 4s + +# 3. Preview what will be synced +Type "esync check" +Enter +Sleep 5s + +# 4. Queue background file edits (fire while the TUI is running) +Hide +Type '(sleep 5 && echo "// refactored handler" >> /tmp/esync-demo/project/cmd/sync.go && sleep 3 && printf "package cmd\n\nfunc Version() string { return \"1.0\" }\n" > /tmp/esync-demo/project/cmd/version.go && sleep 3 && echo "// updated styles" >> /tmp/esync-demo/project/internal/tui/styles.go) &' +Enter +Sleep 300ms +Show + +# 5. Launch the TUI — file changes sync in real time +Type "esync sync" +Enter +Sleep 16s + +# 6. Quit +Type "q" +Sleep 1s diff --git a/esync.toml.example b/esync.toml.example @@ -79,6 +79,16 @@ archive = true # Default: true compress = true +# Delete files on the remote that no longer exist locally (--delete). +# This makes the remote an exact mirror of the local directory. +# Default: false +delete = false + +# Follow all symlinks and copy the files they point to (--copy-links). +# When false, only symlinks pointing outside the source tree are dereferenced. +# Default: false +copy_links = false + # Keep incremental backups of overwritten files on the remote. # Default: false backup = false diff --git a/internal/config/config.go b/internal/config/config.go @@ -35,6 +35,8 @@ type SyncSection struct { type RsyncSettings struct { Archive bool `mapstructure:"archive"` Compress bool `mapstructure:"compress"` + Delete bool `mapstructure:"delete"` + CopyLinks bool `mapstructure:"copy_links"` Backup bool `mapstructure:"backup"` BackupDir string `mapstructure:"backup_dir"` Progress bool `mapstructure:"progress"` @@ -80,6 +82,8 @@ func Load(path string) (*Config, error) { v.SetDefault("settings.initial_sync", false) v.SetDefault("settings.rsync.archive", true) v.SetDefault("settings.rsync.compress", true) + v.SetDefault("settings.rsync.delete", false) + v.SetDefault("settings.rsync.copy_links", false) v.SetDefault("settings.rsync.backup", false) v.SetDefault("settings.rsync.backup_dir", ".rsync_backup") v.SetDefault("settings.rsync.progress", true) @@ -199,6 +203,8 @@ ignore = [".git", "node_modules", ".DS_Store"] [settings.rsync] archive = true compress = true +delete = false +copy_links = false backup = false backup_dir = ".rsync_backup" progress = true diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go @@ -17,13 +17,19 @@ import ( // Types // --------------------------------------------------------------------------- +// FileEntry records a transferred file and its size in bytes. +type FileEntry struct { + Name string + Bytes int64 +} + // Result captures the outcome of a sync operation. type Result struct { Success bool FilesCount int BytesTotal int64 Duration time.Duration - Files []string + Files []FileEntry ErrorMessage string } @@ -46,13 +52,33 @@ func New(cfg *config.Config) *Syncer { // Public methods // --------------------------------------------------------------------------- +// CheckRsync verifies that rsync is installed and returns its version string. +// Returns an error if rsync is not found on PATH. +func CheckRsync() (string, error) { + out, err := exec.Command("rsync", "--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) + } + // First line is "rsync version X.Y.Z protocol version N" + firstLine := strings.SplitN(string(out), "\n", 2)[0] + return strings.TrimSpace(firstLine), nil +} + // 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", "--copy-unsafe-links"} + args := []string{"rsync", "--recursive", "--times", "--progress", "--stats"} rsync := s.cfg.Settings.Rsync + // Symlink handling: --copy-links dereferences all symlinks, + // --copy-unsafe-links only dereferences symlinks pointing outside the tree. + if rsync.CopyLinks { + args = append(args, "--copy-links") + } else { + args = append(args, "--copy-unsafe-links") + } + // Conditional flags if rsync.Archive { args = append(args, "--archive") @@ -60,6 +86,9 @@ func (s *Syncer) BuildCommand() []string { if rsync.Compress { args = append(args, "--compress") } + if rsync.Delete { + args = append(args, "--delete") + } if rsync.Backup { args = append(args, "--backup") if rsync.BackupDir != "" { @@ -176,45 +205,66 @@ func (s *Syncer) buildDestination() string { return fmt.Sprintf("%s:%s", ssh.Host, remote) } -// extractFiles extracts file names from rsync output. -// rsync lists transferred files one per line between the header -// "sending incremental file list" and the blank line before stats. -func (s *Syncer) extractFiles(output string) []string { - var files []string +// reProgressSize matches the final size in a progress line, e.g. +// " 8772 100% 61.99MB/s 00:00:00 (xfer#1, to-check=2/4)" +var reProgressSize = regexp.MustCompile(`^\s*([\d,]+)\s+100%`) + +// extractFiles extracts transferred file names and per-file sizes from +// rsync --progress output. Each filename line is followed by one or more +// progress lines; the final one (with "100%") contains the file size. +func (s *Syncer) extractFiles(output string) []FileEntry { + var files []FileEntry lines := strings.Split(output, "\n") - inList := false + + var pending string // last seen filename awaiting a size for _, line := range lines { - line = strings.TrimSpace(line) + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) - if line == "sending incremental file list" { - inList = true + if trimmed == "" || trimmed == "sending incremental file list" { continue } - if !inList { + // Stop at stats section + if strings.HasPrefix(trimmed, "Number of") || + strings.HasPrefix(trimmed, "sent ") || + strings.HasPrefix(trimmed, "total size") { + break + } + + // Skip directory entries + if strings.HasSuffix(trimmed, "/") || trimmed == "." || trimmed == "./" { continue } - // End of file list: blank line or stats line - if line == "" || strings.HasPrefix(line, "sent ") || strings.HasPrefix(line, "total size") { - if line == "" { - continue - } - break + // Check if this is a progress line (contains 100%) + if m := reProgressSize.FindStringSubmatch(trimmed); len(m) > 1 && pending != "" { + cleaned := strings.ReplaceAll(m[1], ",", "") + size, _ := strconv.ParseInt(cleaned, 10, 64) + files = append(files, FileEntry{Name: pending, Bytes: size}) + pending = "" + continue } - // Skip progress lines (contain % or bytes/sec mid-line) - if strings.Contains(line, "%") || strings.Contains(line, "bytes/sec") { + // Skip other progress lines (partial %, bytes/sec) + if strings.Contains(trimmed, "%") || strings.Contains(trimmed, "bytes/sec") { continue } - // Skip lines starting with "Number of" (stats) - if strings.HasPrefix(line, "Number of") { - break + // Flush any pending file without a matched size + if pending != "" { + files = append(files, FileEntry{Name: pending}) + pending = "" } - files = append(files, line) + // This looks like a filename + pending = trimmed + } + + // Flush last pending + if pending != "" { + files = append(files, FileEntry{Name: pending}) } return files @@ -227,8 +277,8 @@ func (s *Syncer) extractStats(output string) (int, int64) { var count int var totalBytes int64 - // Match "Number of regular files transferred: 3" - reCount := regexp.MustCompile(`Number of regular files transferred:\s*([\d,]+)`) + // Match "Number of regular files transferred: 3" or "Number of files transferred: 2" + reCount := regexp.MustCompile(`Number of (?:regular )?files transferred:\s*([\d,]+)`) if m := reCount.FindStringSubmatch(output); len(m) > 1 { cleaned := strings.ReplaceAll(m[1], ",", "") if n, err := strconv.Atoi(cleaned); err == nil { diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go @@ -238,8 +238,8 @@ total size is 5,678 speedup is 4.40 } expected := []string{"src/main.go", "src/utils.go", "config.toml"} for i, f := range files { - if f != expected[i] { - t.Errorf("files[%d] = %q, want %q", i, f, expected[i]) + if f.Name != expected[i] { + t.Errorf("files[%d].Name = %q, want %q", i, f.Name, expected[i]) } } } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go @@ -181,8 +181,10 @@ func (w *Watcher) eventLoop() { } // isRelevantOp returns true for file-system operations we care about. +// Chmod is included because touch(1) and some editors only update metadata, +// which fsnotify surfaces as Chmod on macOS (kqueue) and Linux (inotify). func isRelevantOp(op fsnotify.Op) bool { - return op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 + return op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename|fsnotify.Chmod) != 0 } // shouldIgnore checks the base name of path against all ignore patterns