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 ```