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