esync

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

commit 4ffb53b16262adb37262e4f101ddb8ab25534386
parent fc6d6720f4f30ffc5afd6fac8942437713243281
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Sun,  8 Mar 2026 15:17:32 -0400

feat: emit rsync include/exclude filter rules from config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Minternal/syncer/syncer.go | 39+++++++++++++++++++++++++++++++++++----
Minternal/syncer/syncer_test.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 4 deletions(-)

diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "regexp" "strconv" "strings" @@ -153,10 +154,40 @@ func (s *Syncer) BuildCommand() []string { args = append(args, "--dry-run") } - // Exclude patterns (strip **/ prefix) - for _, pattern := range s.cfg.AllIgnorePatterns() { - cleaned := strings.TrimPrefix(pattern, "**/") - args = append(args, "--exclude="+cleaned) + // Include/exclude filter rules + if len(s.cfg.Settings.Include) > 0 { + // Emit include rules: ancestor dirs + subtree for each prefix + seen := make(map[string]bool) + for _, inc := range s.cfg.Settings.Include { + inc = filepath.Clean(inc) + // Add ancestor directories (e.g. "docs/api" needs "docs/") + parts := strings.Split(inc, string(filepath.Separator)) + for i := 1; i < len(parts); i++ { + ancestor := strings.Join(parts[:i], "/") + "/" + if !seen[ancestor] { + args = append(args, "--include="+ancestor) + seen[ancestor] = true + } + } + // Add the prefix dir and everything underneath + args = append(args, "--include="+inc+"/") + args = append(args, "--include="+inc+"/**") + } + + // Exclude patterns from ignore lists (applied within included paths) + for _, pattern := range s.cfg.AllIgnorePatterns() { + cleaned := strings.TrimPrefix(pattern, "**/") + args = append(args, "--exclude="+cleaned) + } + + // Catch-all exclude: block everything not explicitly included + args = append(args, "--exclude=*") + } else { + // No include filter — just exclude patterns as before + for _, pattern := range s.cfg.AllIgnorePatterns() { + cleaned := strings.TrimPrefix(pattern, "**/") + args = append(args, "--exclude="+cleaned) + } } // Extra args passthrough diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go @@ -310,6 +310,81 @@ func TestBuildDestination_SSHWithoutUser(t *testing.T) { } // --------------------------------------------------------------------------- +// 8. TestBuildCommand_IncludePatterns — verify include/exclude filter rules +// --------------------------------------------------------------------------- +func TestBuildCommand_IncludePatterns(t *testing.T) { + cfg := minimalConfig("/src", "/dst") + cfg.Settings.Include = []string{"src", "docs/api"} + cfg.Settings.Ignore = []string{".git"} + + s := New(cfg) + cmd := s.BuildCommand() + + // Should have include rules for parent dirs, subtrees, then excludes, then catch-all + if !containsArg(cmd, "--include=src/") { + t.Errorf("missing --include=src/ in %v", cmd) + } + if !containsArg(cmd, "--include=src/**") { + t.Errorf("missing --include=src/** in %v", cmd) + } + if !containsArg(cmd, "--include=docs/") { + t.Errorf("missing --include=docs/ in %v", cmd) + } + if !containsArg(cmd, "--include=docs/api/") { + t.Errorf("missing --include=docs/api/ in %v", cmd) + } + if !containsArg(cmd, "--include=docs/api/**") { + t.Errorf("missing --include=docs/api/** in %v", cmd) + } + if !containsArg(cmd, "--exclude=.git") { + t.Errorf("missing --exclude=.git in %v", cmd) + } + if !containsArg(cmd, "--exclude=*") { + t.Errorf("missing --exclude=* catch-all in %v", cmd) + } + + // Verify ordering: all --include before --exclude=* + lastInclude := -1 + catchAllExclude := -1 + for i, a := range cmd { + if strings.HasPrefix(a, "--include=") { + lastInclude = i + } + if a == "--exclude=*" { + catchAllExclude = i + } + } + if lastInclude >= catchAllExclude { + t.Errorf("--include rules must come before --exclude=* catch-all") + } +} + +// --------------------------------------------------------------------------- +// 9. TestBuildCommand_NoIncludeMeansNoFilterRules — no include = no catch-all +// --------------------------------------------------------------------------- +func TestBuildCommand_NoIncludeMeansNoFilterRules(t *testing.T) { + cfg := minimalConfig("/src", "/dst") + cfg.Settings.Ignore = []string{".git"} + + s := New(cfg) + cmd := s.BuildCommand() + + // Should NOT have --include or --exclude=* catch-all + for _, a := range cmd { + if strings.HasPrefix(a, "--include=") { + t.Errorf("unexpected --include in %v", cmd) + } + } + if containsArg(cmd, "--exclude=*") { + t.Errorf("unexpected --exclude=* catch-all in %v", cmd) + } + // Regular excludes still present + if !containsArg(cmd, "--exclude=.git") { + t.Errorf("missing --exclude=.git in %v", cmd) + } +} + +// --------------------------------------------------------------------------- // Test helpers // ---------------------------------------------------------------------------