esync

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

commit 25994f6e2dc6fb07f8fd9bf982d114c871a00778
parent 4ffe89068e1155e1012e126c8342b703980e3348
Author: Erik Loualiche <[email protected]>
Date:   Sun,  8 Mar 2026 15:39:57 -0400

fix: handle individual files in include patterns, apply include filter in check

- Syncer: emit bare --include=<name> alongside dir/subtree patterns so
  individual files (readme.md, Snakefile) are matched by rsync
- check command: apply include filtering during file walk (was only
  checking ignore patterns)
- Add TestBuildCommand_IncludeFiles for file-level include patterns

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
Mcmd/check.go | 31+++++++++++++++++++++++++++++++
Minternal/syncer/syncer.go | 3++-
Minternal/syncer/syncer_test.go | 52+++++++++++++++++++++++++++++++++++++---------------
3 files changed, 70 insertions(+), 16 deletions(-)

diff --git a/cmd/check.go b/cmd/check.go @@ -83,6 +83,7 @@ type fileEntry struct { func printPreview(cfg *config.Config) error { localDir := cfg.Sync.Local patterns := cfg.AllIgnorePatterns() + includes := cfg.Settings.Include var included []fileEntry var excluded []fileEntry @@ -114,6 +115,14 @@ func printPreview(cfg *config.Config) error { } } + // Check against include patterns (if any) + if len(includes) > 0 && !matchesInclude(rel, includes) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if !info.IsDir() { included = append(included, fileEntry{path: rel}) includedSize += info.Size() @@ -172,6 +181,28 @@ func printPreview(cfg *config.Config) error { // Pattern matching // --------------------------------------------------------------------------- +// matchesInclude checks whether a relative path falls under any include prefix. +// A path is included if it equals a prefix, is inside a prefix, or is an +// ancestor directory needed to reach a prefix. +func matchesInclude(rel string, includes []string) bool { + for _, inc := range includes { + inc = filepath.Clean(inc) + // Exact match (file or dir) + if rel == inc { + return true + } + // Path is inside the included prefix + if strings.HasPrefix(rel, inc+string(filepath.Separator)) { + return true + } + // Path is an ancestor of the included prefix + if strings.HasPrefix(inc, rel+string(filepath.Separator)) { + return true + } + } + return false +} + // matchesIgnorePattern checks whether a file (given its relative path and // file info) matches a single ignore pattern. It handles bracket/quote // stripping, ** prefixes, and directory-specific patterns. diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go @@ -169,7 +169,8 @@ func (s *Syncer) BuildCommand() []string { seen[ancestor] = true } } - // Add the prefix dir and everything underneath + // Include as both file and directory (we don't know which it is) + args = append(args, "--include="+inc) args = append(args, "--include="+inc+"/") args = append(args, "--include="+inc+"/**") } diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go @@ -320,21 +320,20 @@ func TestBuildCommand_IncludePatterns(t *testing.T) { 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) + // Should have include rules: file match, dir match, subtree for each prefix + // Plus ancestor dirs for nested paths + for _, expected := range []string{ + "--include=src", // file match + "--include=src/", // dir match + "--include=src/**", // subtree + "--include=docs/", // ancestor dir + "--include=docs/api", // file match + "--include=docs/api/", // dir match + "--include=docs/api/**", // subtree + } { + if !containsArg(cmd, expected) { + t.Errorf("missing %s in %v", expected, cmd) + } } if !containsArg(cmd, "--exclude=.git") { t.Errorf("missing --exclude=.git in %v", cmd) @@ -396,6 +395,29 @@ func TestBuildCommand_NoIncludeMeansNoFilterRules(t *testing.T) { } // --------------------------------------------------------------------------- +// 10. TestBuildCommand_IncludeFiles — individual files in include list +// --------------------------------------------------------------------------- +func TestBuildCommand_IncludeFiles(t *testing.T) { + cfg := minimalConfig("/src", "/dst") + cfg.Settings.Include = []string{"readme.md", "Snakefile"} + + s := New(cfg) + cmd := s.BuildCommand() + + // Individual files get a bare --include (no trailing /) + if !containsArg(cmd, "--include=readme.md") { + t.Errorf("missing --include=readme.md in %v", cmd) + } + if !containsArg(cmd, "--include=Snakefile") { + t.Errorf("missing --include=Snakefile in %v", cmd) + } + // Catch-all must be present + if !containsArg(cmd, "--exclude=*") { + t.Errorf("missing --exclude=* catch-all in %v", cmd) + } +} + +// --------------------------------------------------------------------------- // Test helpers // ---------------------------------------------------------------------------