commit 25994f6e2dc6fb07f8fd9bf982d114c871a00778
parent 4ffe89068e1155e1012e126c8342b703980e3348
Author: Erik Loualiche <eloualic@umn.edu>
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 <noreply@anthropic.com>
Diffstat:
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
// ---------------------------------------------------------------------------