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:
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