esync

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

2026-03-08-include-filter-plan.md (14226B)


      1 # Include Filter Implementation Plan
      2 
      3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
      4 
      5 **Goal:** Add `settings.include` path-prefix filtering so users can narrow syncs to specific subtrees, applied to both watcher and rsync.
      6 
      7 **Architecture:** A single `Include []string` field on `Settings` feeds into both the watcher (skip directories outside included prefixes) and the syncer (rsync `--include`/`--exclude` filter rules). Empty list means include everything (backwards compatible).
      8 
      9 **Tech Stack:** Go, fsnotify, rsync filter rules
     10 
     11 ---
     12 
     13 ### Task 1: Add `Include` field to config
     14 
     15 **Files:**
     16 - Modify: `internal/config/config.go:54-60` (Settings struct)
     17 - Modify: `internal/config/config.go:183-218` (DefaultTOML)
     18 - Test: `internal/config/config_test.go`
     19 
     20 **Step 1: Write the failing test**
     21 
     22 Add to `internal/config/config_test.go`:
     23 
     24 ```go
     25 func TestLoadConfigWithInclude(t *testing.T) {
     26 	toml := `
     27 [sync]
     28 local  = "/src"
     29 remote = "/dst"
     30 
     31 [settings]
     32 include = ["src", "docs/api"]
     33 ignore  = [".git"]
     34 `
     35 	path := writeTempTOML(t, toml)
     36 	cfg, err := Load(path)
     37 	if err != nil {
     38 		t.Fatalf("Load returned error: %v", err)
     39 	}
     40 
     41 	if len(cfg.Settings.Include) != 2 {
     42 		t.Fatalf("Settings.Include length = %d, want 2", len(cfg.Settings.Include))
     43 	}
     44 	if cfg.Settings.Include[0] != "src" || cfg.Settings.Include[1] != "docs/api" {
     45 		t.Errorf("Settings.Include = %v, want [src docs/api]", cfg.Settings.Include)
     46 	}
     47 }
     48 
     49 func TestLoadConfigIncludeDefaultsToEmpty(t *testing.T) {
     50 	toml := `
     51 [sync]
     52 local  = "/src"
     53 remote = "/dst"
     54 `
     55 	path := writeTempTOML(t, toml)
     56 	cfg, err := Load(path)
     57 	if err != nil {
     58 		t.Fatalf("Load returned error: %v", err)
     59 	}
     60 
     61 	if cfg.Settings.Include == nil {
     62 		// nil is fine — treated as "include everything"
     63 	} else if len(cfg.Settings.Include) != 0 {
     64 		t.Errorf("Settings.Include = %v, want empty", cfg.Settings.Include)
     65 	}
     66 }
     67 ```
     68 
     69 **Step 2: Run test to verify it fails**
     70 
     71 Run: `go test ./internal/config/ -run TestLoadConfigWithInclude -v`
     72 Expected: FAIL — `cfg.Settings.Include` has no such field
     73 
     74 **Step 3: Write minimal implementation**
     75 
     76 In `internal/config/config.go`, add `Include` field to `Settings` struct (line 57, after `InitialSync`):
     77 
     78 ```go
     79 type Settings struct {
     80 	WatcherDebounce int           `mapstructure:"watcher_debounce"`
     81 	InitialSync     bool          `mapstructure:"initial_sync"`
     82 	Include         []string      `mapstructure:"include"`
     83 	Ignore          []string      `mapstructure:"ignore"`
     84 	Rsync           RsyncSettings `mapstructure:"rsync"`
     85 	Log             LogSettings   `mapstructure:"log"`
     86 }
     87 ```
     88 
     89 Update `DefaultTOML()` — add `include` line after `initial_sync` with a comment:
     90 
     91 ```go
     92 // In the DefaultTOML string, after initial_sync line:
     93 initial_sync     = false
     94 # include: path prefixes to sync (relative to local). Empty means everything.
     95 # Keep include simple and explicit; use ignore for fine-grained filtering.
     96 include          = []
     97 ignore           = [".git", "node_modules", ".DS_Store"]
     98 ```
     99 
    100 **Step 4: Run test to verify it passes**
    101 
    102 Run: `go test ./internal/config/ -run "TestLoadConfigWithInclude|TestLoadConfigIncludeDefaultsToEmpty" -v`
    103 Expected: PASS
    104 
    105 **Step 5: Commit**
    106 
    107 ```bash
    108 git add internal/config/config.go internal/config/config_test.go
    109 git commit -m "feat: add Include field to Settings config"
    110 ```
    111 
    112 ---
    113 
    114 ### Task 2: Add `shouldInclude` to watcher
    115 
    116 **Files:**
    117 - Modify: `internal/watcher/watcher.go:88-94` (Watcher struct)
    118 - Modify: `internal/watcher/watcher.go:100-120` (New function)
    119 - Modify: `internal/watcher/watcher.go:205-224` (addRecursive)
    120 - Modify: `internal/watcher/watcher.go:145-181` (eventLoop)
    121 - Test: `internal/watcher/watcher_test.go`
    122 
    123 **Step 1: Write the failing test**
    124 
    125 Add to `internal/watcher/watcher_test.go`:
    126 
    127 ```go
    128 func TestShouldInclude(t *testing.T) {
    129 	w := &Watcher{
    130 		rootPath: "/project",
    131 		includes: []string{"src", "docs/api"},
    132 	}
    133 
    134 	tests := []struct {
    135 		path   string
    136 		expect bool
    137 	}{
    138 		// Files/dirs inside included prefixes
    139 		{"/project/src/main.go", true},
    140 		{"/project/src/pkg/util.go", true},
    141 		{"/project/docs/api/readme.md", true},
    142 		// Ancestor dirs needed for traversal
    143 		{"/project/docs", true},
    144 		// Outside included prefixes
    145 		{"/project/tmp/cache.bin", false},
    146 		{"/project/build/out.o", false},
    147 		// Root itself is always included
    148 		{"/project", true},
    149 	}
    150 
    151 	for _, tt := range tests {
    152 		got := w.shouldInclude(tt.path)
    153 		if got != tt.expect {
    154 			t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect)
    155 		}
    156 	}
    157 }
    158 
    159 func TestShouldIncludeEmptyMeansAll(t *testing.T) {
    160 	w := &Watcher{
    161 		rootPath: "/project",
    162 		includes: nil,
    163 	}
    164 
    165 	tests := []struct {
    166 		path   string
    167 		expect bool
    168 	}{
    169 		{"/project/anything/at/all.go", true},
    170 		{"/project/tmp/cache.bin", true},
    171 	}
    172 
    173 	for _, tt := range tests {
    174 		got := w.shouldInclude(tt.path)
    175 		if got != tt.expect {
    176 			t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect)
    177 		}
    178 	}
    179 }
    180 ```
    181 
    182 **Step 2: Run test to verify it fails**
    183 
    184 Run: `go test ./internal/watcher/ -run "TestShouldInclude" -v`
    185 Expected: FAIL — `rootPath` and `includes` fields don't exist, `shouldInclude` method doesn't exist
    186 
    187 **Step 3: Write minimal implementation**
    188 
    189 Add `includes` and `rootPath` fields to `Watcher` struct:
    190 
    191 ```go
    192 type Watcher struct {
    193 	fsw       *fsnotify.Watcher
    194 	debouncer *Debouncer
    195 	path      string
    196 	rootPath  string
    197 	ignores   []string
    198 	includes  []string
    199 	done      chan struct{}
    200 }
    201 ```
    202 
    203 Update `New()` signature to accept includes:
    204 
    205 ```go
    206 func New(path string, debounceMs int, ignores []string, includes []string, handler EventHandler) (*Watcher, error) {
    207 ```
    208 
    209 Set `rootPath` and `includes` in the constructor:
    210 
    211 ```go
    212 	abs, err := filepath.Abs(path)
    213 	if err != nil {
    214 		abs = path
    215 	}
    216 
    217 	w := &Watcher{
    218 		fsw:      fsw,
    219 		path:     path,
    220 		rootPath: abs,
    221 		ignores:  ignores,
    222 		includes: includes,
    223 		done:     make(chan struct{}),
    224 	}
    225 ```
    226 
    227 Add `shouldInclude` method:
    228 
    229 ```go
    230 // shouldInclude checks whether path falls under an included prefix.
    231 // If includes is empty, everything is included. A path is included if:
    232 // - it IS a prefix of an include path (ancestor dir needed for traversal), or
    233 // - it is prefixed BY an include path (file/dir inside included subtree).
    234 func (w *Watcher) shouldInclude(path string) bool {
    235 	if len(w.includes) == 0 {
    236 		return true
    237 	}
    238 
    239 	abs, err := filepath.Abs(path)
    240 	if err != nil {
    241 		abs = path
    242 	}
    243 
    244 	rel, err := filepath.Rel(w.rootPath, abs)
    245 	if err != nil || rel == "." {
    246 		return true // root itself is always included
    247 	}
    248 
    249 	for _, inc := range w.includes {
    250 		incClean := filepath.Clean(inc)
    251 		// Path is inside the included prefix (e.g. rel="src/main.go", inc="src")
    252 		if rel == incClean || strings.HasPrefix(rel, incClean+string(filepath.Separator)) {
    253 			return true
    254 		}
    255 		// Path is an ancestor of the included prefix (e.g. rel="docs", inc="docs/api")
    256 		if strings.HasPrefix(incClean, rel+string(filepath.Separator)) {
    257 			return true
    258 		}
    259 	}
    260 	return false
    261 }
    262 ```
    263 
    264 Update `addRecursive` to check includes before watching:
    265 
    266 ```go
    267 func (w *Watcher) addRecursive(path string) error {
    268 	return filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
    269 		if err != nil {
    270 			return nil
    271 		}
    272 
    273 		if w.shouldIgnore(p) {
    274 			if info.IsDir() {
    275 				return filepath.SkipDir
    276 			}
    277 			return nil
    278 		}
    279 
    280 		if !w.shouldInclude(p) {
    281 			if info.IsDir() {
    282 				return filepath.SkipDir
    283 			}
    284 			return nil
    285 		}
    286 
    287 		if info.IsDir() {
    288 			return w.fsw.Add(p)
    289 		}
    290 
    291 		return nil
    292 	})
    293 }
    294 ```
    295 
    296 Update `eventLoop` to check includes:
    297 
    298 ```go
    299 		case event, ok := <-w.fsw.Events:
    300 			if !ok {
    301 				return
    302 			}
    303 
    304 			if !isRelevantOp(event.Op) {
    305 				continue
    306 			}
    307 
    308 			if w.shouldIgnore(event.Name) {
    309 				continue
    310 			}
    311 
    312 			if !w.shouldInclude(event.Name) {
    313 				continue
    314 			}
    315 ```
    316 
    317 **Step 4: Run test to verify it passes**
    318 
    319 Run: `go test ./internal/watcher/ -run "TestShouldInclude" -v`
    320 Expected: PASS
    321 
    322 **Step 5: Commit**
    323 
    324 ```bash
    325 git add internal/watcher/watcher.go internal/watcher/watcher_test.go
    326 git commit -m "feat: add shouldInclude path-prefix filtering to watcher"
    327 ```
    328 
    329 ---
    330 
    331 ### Task 3: Update watcher.New callers
    332 
    333 **Files:**
    334 - Modify: `cmd/sync.go:263-266` (TUI mode watcher init)
    335 - Modify: `cmd/sync.go:359-362` (daemon mode watcher init)
    336 - Modify: `integration_test.go:112` (integration test)
    337 
    338 **Step 1: Update all `watcher.New()` call sites**
    339 
    340 The signature changed from `New(path, debounceMs, ignores, handler)` to `New(path, debounceMs, ignores, includes, handler)`.
    341 
    342 In `cmd/sync.go`, both call sites (around lines 263 and 359) currently look like:
    343 
    344 ```go
    345 w, err := watcher.New(
    346     cfg.Sync.Local,
    347     cfg.Settings.WatcherDebounce,
    348     cfg.AllIgnorePatterns(),
    349     syncHandler,
    350 )
    351 ```
    352 
    353 Change to:
    354 
    355 ```go
    356 w, err := watcher.New(
    357     cfg.Sync.Local,
    358     cfg.Settings.WatcherDebounce,
    359     cfg.AllIgnorePatterns(),
    360     cfg.Settings.Include,
    361     syncHandler,
    362 )
    363 ```
    364 
    365 In `integration_test.go` (line 112):
    366 
    367 ```go
    368 w, err := watcher.New(src, 100, nil, nil, handler)
    369 ```
    370 
    371 **Step 2: Verify it compiles and tests pass**
    372 
    373 Run: `go build ./... && go test ./...`
    374 Expected: all pass
    375 
    376 **Step 3: Commit**
    377 
    378 ```bash
    379 git add cmd/sync.go integration_test.go
    380 git commit -m "feat: pass include patterns to watcher from config"
    381 ```
    382 
    383 ---
    384 
    385 ### Task 4: Add rsync include filter rules to syncer
    386 
    387 **Files:**
    388 - Modify: `internal/syncer/syncer.go:156-160` (exclude patterns section in BuildCommand)
    389 - Test: `internal/syncer/syncer_test.go`
    390 
    391 **Step 1: Write the failing test**
    392 
    393 Add to `internal/syncer/syncer_test.go`:
    394 
    395 ```go
    396 func TestBuildCommand_IncludePatterns(t *testing.T) {
    397 	cfg := minimalConfig("/src", "/dst")
    398 	cfg.Settings.Include = []string{"src", "docs/api"}
    399 	cfg.Settings.Ignore = []string{".git"}
    400 
    401 	s := New(cfg)
    402 	cmd := s.BuildCommand()
    403 
    404 	// Should have include rules for parent dirs, subtrees, then excludes, then catch-all
    405 	// Order: --include=src/ --include=src/** --include=docs/ --include=docs/api/ --include=docs/api/** --exclude=.git --exclude=*
    406 	if !containsArg(cmd, "--include=src/") {
    407 		t.Errorf("missing --include=src/ in %v", cmd)
    408 	}
    409 	if !containsArg(cmd, "--include=src/**") {
    410 		t.Errorf("missing --include=src/** in %v", cmd)
    411 	}
    412 	if !containsArg(cmd, "--include=docs/") {
    413 		t.Errorf("missing --include=docs/ in %v", cmd)
    414 	}
    415 	if !containsArg(cmd, "--include=docs/api/") {
    416 		t.Errorf("missing --include=docs/api/ in %v", cmd)
    417 	}
    418 	if !containsArg(cmd, "--include=docs/api/**") {
    419 		t.Errorf("missing --include=docs/api/** in %v", cmd)
    420 	}
    421 	if !containsArg(cmd, "--exclude=.git") {
    422 		t.Errorf("missing --exclude=.git in %v", cmd)
    423 	}
    424 	if !containsArg(cmd, "--exclude=*") {
    425 		t.Errorf("missing --exclude=* catch-all in %v", cmd)
    426 	}
    427 
    428 	// Verify ordering: all --include before --exclude=*
    429 	lastInclude := -1
    430 	catchAllExclude := -1
    431 	for i, a := range cmd {
    432 		if strings.HasPrefix(a, "--include=") {
    433 			lastInclude = i
    434 		}
    435 		if a == "--exclude=*" {
    436 			catchAllExclude = i
    437 		}
    438 	}
    439 	if lastInclude >= catchAllExclude {
    440 		t.Errorf("--include rules must come before --exclude=* catch-all")
    441 	}
    442 }
    443 
    444 func TestBuildCommand_NoIncludeMeansNoFilterRules(t *testing.T) {
    445 	cfg := minimalConfig("/src", "/dst")
    446 	cfg.Settings.Ignore = []string{".git"}
    447 
    448 	s := New(cfg)
    449 	cmd := s.BuildCommand()
    450 
    451 	// Should NOT have --include or --exclude=* catch-all
    452 	for _, a := range cmd {
    453 		if strings.HasPrefix(a, "--include=") {
    454 			t.Errorf("unexpected --include in %v", cmd)
    455 		}
    456 	}
    457 	if containsArg(cmd, "--exclude=*") {
    458 		t.Errorf("unexpected --exclude=* catch-all in %v", cmd)
    459 	}
    460 	// Regular excludes still present
    461 	if !containsArg(cmd, "--exclude=.git") {
    462 		t.Errorf("missing --exclude=.git in %v", cmd)
    463 	}
    464 }
    465 ```
    466 
    467 **Step 2: Run test to verify it fails**
    468 
    469 Run: `go test ./internal/syncer/ -run "TestBuildCommand_IncludePatterns|TestBuildCommand_NoIncludeMeansNoFilterRules" -v`
    470 Expected: FAIL — no include logic exists yet
    471 
    472 **Step 3: Write minimal implementation**
    473 
    474 Replace the exclude patterns section in `BuildCommand()` (lines 156-160) with:
    475 
    476 ```go
    477 	// Include/exclude filter rules
    478 	if len(s.cfg.Settings.Include) > 0 {
    479 		// Emit include rules: ancestor dirs + subtree for each prefix
    480 		seen := make(map[string]bool)
    481 		for _, inc := range s.cfg.Settings.Include {
    482 			inc = filepath.Clean(inc)
    483 			// Add ancestor directories (e.g. "docs/api" needs "docs/")
    484 			parts := strings.Split(inc, string(filepath.Separator))
    485 			for i := 1; i < len(parts); i++ {
    486 				ancestor := strings.Join(parts[:i], "/") + "/"
    487 				if !seen[ancestor] {
    488 					args = append(args, "--include="+ancestor)
    489 					seen[ancestor] = true
    490 				}
    491 			}
    492 			// Add the prefix dir and everything underneath
    493 			args = append(args, "--include="+inc+"/")
    494 			args = append(args, "--include="+inc+"/**")
    495 		}
    496 
    497 		// Exclude patterns from ignore lists (applied within included paths)
    498 		for _, pattern := range s.cfg.AllIgnorePatterns() {
    499 			cleaned := strings.TrimPrefix(pattern, "**/")
    500 			args = append(args, "--exclude="+cleaned)
    501 		}
    502 
    503 		// Catch-all exclude: block everything not explicitly included
    504 		args = append(args, "--exclude=*")
    505 	} else {
    506 		// No include filter — just exclude patterns as before
    507 		for _, pattern := range s.cfg.AllIgnorePatterns() {
    508 			cleaned := strings.TrimPrefix(pattern, "**/")
    509 			args = append(args, "--exclude="+cleaned)
    510 		}
    511 	}
    512 ```
    513 
    514 Add `"path/filepath"` to the imports in `syncer.go` if not already present.
    515 
    516 **Step 4: Run test to verify it passes**
    517 
    518 Run: `go test ./internal/syncer/ -run "TestBuildCommand_IncludePatterns|TestBuildCommand_NoIncludeMeansNoFilterRules" -v`
    519 Expected: PASS
    520 
    521 **Step 5: Run all tests**
    522 
    523 Run: `go test ./...`
    524 Expected: all pass
    525 
    526 **Step 6: Commit**
    527 
    528 ```bash
    529 git add internal/syncer/syncer.go internal/syncer/syncer_test.go
    530 git commit -m "feat: emit rsync include/exclude filter rules from config"
    531 ```
    532 
    533 ---
    534 
    535 ### Task 5: Update DefaultTOML test and verify end-to-end
    536 
    537 **Files:**
    538 - Modify: `internal/config/config_test.go` (TestDefaultTOML)
    539 
    540 **Step 1: Update TestDefaultTOML to verify include is documented**
    541 
    542 Add a check in the existing `TestDefaultTOML`:
    543 
    544 ```go
    545 // In TestDefaultTOML, add to the sections check:
    546 if !containsString(toml, "include") {
    547     t.Error("DefaultTOML() missing include field")
    548 }
    549 ```
    550 
    551 **Step 2: Run all tests**
    552 
    553 Run: `go test ./...`
    554 Expected: all pass
    555 
    556 **Step 3: Commit**
    557 
    558 ```bash
    559 git add internal/config/config_test.go
    560 git commit -m "test: verify DefaultTOML includes the include field"
    561 ```