diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e35e2..e39854c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## v1.5.4 (2026-06-27) +- **修复 Bug**: 修复了配置 `Types`(文件类型白名单)时,目录本身的创建/删除等事件未能被正确过滤的问题。 +- **CLI 体验大幅升级**: + - **紧凑型 UI**: 重新设计了日志输出(Banner 与 Event),视觉更加紧凑专业,彻底分离了应用输出与 Watch 日志。 + - **智能追加参数**: 当使用 `--go` 等预设并提供自定义参数(如 `watch --go --dev`)时,CLI 会智能判断并将其追加到默认命令(如 `go run . -- --dev`)。 + - **.gitignore 支持**: 默认自动读取当前目录的 `.gitignore` 与 `.ignore` 文件并将其中的规则加入排除列表。 + - **.watch 任务文件**: 支持基于 `.watch` 文件的极简任务配置(键值对格式),可无参数直接执行默认任务,或通过 `watch --taskname` 执行特定任务。 + - **清屏支持**: 新增 `-c, --clear` 参数,在重启前自动清理终端。 + - **手动重启**: 支持在运行时键入 `rs` + 回车强行重启服务。 + - **恢复参数**: 重新暴露 `-E, --exclude-ext` 选项(原 `-exclude-type`)方便灵活排除特定后缀。 + - **参数标准化**: `-path` 改为 `-w/--watch`,`-type` 改为 `-e/--ext`,`-exclude-path` 改为 `-i/--ignore` 等。 + ## v1.5.3 (2026-06-22) - **新增 CLI**: 新增 `watch/watch` 子包,提供 `watch` 命令行工具,支持 `go install apigo.cc/go/watch/watch@latest` 安装。 - 支持 `-type`/`-exclude-type`/`-exclude-path` 等 watch.Start 全部配置能力。 diff --git a/README.md b/README.md index 27fe63b..041726b 100644 --- a/README.md +++ b/README.md @@ -26,37 +26,54 @@ go get apigo.cc/go/watch go install apigo.cc/go/watch/watch@latest ``` +**快速体验**: ```bash -# Go 开发,变更时重新运行测试 -watch -go go test ./... +# Go 开发:无需提供命令,默认自动执行 go run . (并支持智能追加参数) +watch --go +watch --go --dev # 实际执行: go run . -- --dev -# Web 前端,变更时自动构建 -watch -web npm run build +# Go 测试:默认执行 go test -v -count=1 ./... +watch --gotest -# Python 开发,变更时重启脚本 -watch -py python main.py --dev +# Web 前端:默认执行 npm run dev +watch --web -# 自定义:监听 src 目录的 Go 文件,变更时运行 -watch -path ./src -type go go run . - -# 仅打印变更事件(不执行命令) -watch -go +# 自定义执行命令(清屏并编译) +watch -c -e go -i "vendor/**" go build -o app ``` +**任务文件 `.watch`**: +支持在项目根目录创建 `.watch` 文件(键值对格式),实现极简任务流。自动忽略文件中的空行和 `#` 注释。 +```ini +dev = -c --go +test = --gotest +build = -c -e go -i vendor/** go build -o app +``` +- 运行 `watch --dev`:直接加载 `dev` 的配置运行。 +- 运行 `watch`(无参数):自动执行第一项任务! + +**自动排除 `.gitignore`**: +`watch` 默认会自动读取当前目录下的 `.gitignore` 和 `.ignore` 文件,并将其中的规则加入排除列表,**完全开箱即用**。 + +**交互式重启**: +当程序运行在后台时,在终端内敲击 `rs` 并回车,即可强行手动重启。 + **参数说明**: | 参数 | 说明 | |---|---| -| `-path` | 监听路径(默认 `.`,递归) | -| `-type` | 包含文件类型,逗号分隔 | -| `-exclude-type` | 排除文件类型,逗号分隔 | -| `-exclude-path` | 排除路径模式,可重复(gitignore 语义) | -| `-go` | Go 开发预设(go \| vendor/\*\*, \*\*/node_modules/\*\*) | -| `-web` | Web 前端预设(html,css,js,ts,vue,jsx,tsx \| node_modules/\*\*, dist/\*\*) | -| `-js` | Node.js 预设(js,ts,json \| node_modules/\*\*) | -| `-py` | Python 预设(py,toml,yaml,yml \| \_\_pycache\_\_/\*\*, .venv/\*\*) | +| `-w, --watch` | 监听路径(默认 `.`,递归) | +| `-e, --ext` | 包含文件类型,逗号分隔 (如 `go,js`) | +| `-E, --exclude-ext` | 排除文件类型,逗号分隔 | +| `-i, --ignore` | 排除路径模式,可重复(gitignore 语义) | +| `-c, --clear` | 重启前自动清屏 | +| `--go` | Go 预设 (自动补齐 `go run .` 与忽略路径) | +| `--gotest` | Go 测试预设 (自动补齐 `go test -v -count=1 ./...`) | +| `--web` | Web 预设 (自动补齐 `npm run dev`) | +| `--js` | Node.js 预设 (自动补齐 `npm start`) | +| `--py` | Python 预设 (自动补齐 `python main.py`) | -预设值会与用户显式指定的参数合并追加,不会互相替换。 +预设值会与用户显式指定的参数合并追加,遇到显式声明的新命令时,会自动替换预设默认命令。 ## API 指南 diff --git a/TEST.md b/TEST.md index a7282d7..4048a81 100644 --- a/TEST.md +++ b/TEST.md @@ -1,17 +1,19 @@ # Test Report ## 单元测试结果 -执行时间: 2026-06-22 +执行时间: 2026-06-27 ``` === RUN TestWatch --- PASS: TestWatch (0.21s) +=== RUN TestDirEventWithTypeFilter +--- PASS: TestDirEventWithTypeFilter (0.15s) === RUN TestDebounce --- PASS: TestDebounce (0.26s) === RUN TestEasyStart --- PASS: TestEasyStart (0.00s) PASS -ok apigo.cc/go/watch 0.852s +ok apigo.cc/go/watch 1.018s ``` ## 性能测试结果 (Benchmark) @@ -33,3 +35,4 @@ BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op 4. **路径归一化**: 验证绝对路径下的准确匹配。 5. **极简接口**: 验证 `EasyStart` 的可用性。 6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。 +7. **类型白名单与目录过滤**: 验证开启 Types 白名单时,正常过滤目录变更事件,不误触发回调 (`TestDirEventWithTypeFilter`)。 diff --git a/watch.go b/watch.go index 0cf9374..a9380db 100644 --- a/watch.go +++ b/watch.go @@ -251,8 +251,13 @@ func (watcher *Watcher) isMatch(path string, et EventType, isDir bool) bool { return false } - // 3. 类型过滤 (仅针对文件) - if !isDir { + // 3. 类型过滤 + if isDir { + // 目录没有后缀,若用户配置了文件类型白名单,则目录事件不匹配 + if len(watcher.typeMap) > 0 { + return false + } + } else { ext := filepath.Ext(path) if len(watcher.exTypeMap) > 0 { if _, ok := watcher.exTypeMap[ext]; ok { diff --git a/watch/main.go b/watch/main.go index 1d9656f..3cad95c 100644 --- a/watch/main.go +++ b/watch/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "os" "os/signal" @@ -12,78 +13,142 @@ import ( "apigo.cc/go/watch" ) +// ── ANSI 颜色常量 ────────────────────────────────────────────────────────────── + +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + blue = "\033[34m" + cyan = "\033[36m" + white = "\033[37m" + gray = "\033[90m" + + bgBlue = "\033[44m" + bgGreen = "\033[42m" +) + const debounceTime = 300 * time.Millisecond +// ── Preset 定义 ──────────────────────────────────────────────────────────────── + type preset struct { - Types []string - ExcludePaths []string + Types []string + ExcludePaths []string + DefaultCommand []string } var presets = map[string]preset{ "go": { - Types: []string{"go"}, - ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, + Types: []string{"go"}, + ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, + DefaultCommand: []string{"go", "run", "."}, + }, + "gotest": { + Types: []string{"go"}, + ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, + DefaultCommand: []string{"go", "test", "-v", "-count=1", "./..."}, }, "web": { - Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"}, - ExcludePaths: []string{"node_modules/**", "dist/**"}, + Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"}, + ExcludePaths: []string{"node_modules/**", "dist/**"}, + DefaultCommand: []string{"npm", "run", "dev"}, }, "js": { - Types: []string{"js", "ts", "json"}, - ExcludePaths: []string{"node_modules/**"}, + Types: []string{"js", "ts", "json"}, + ExcludePaths: []string{"node_modules/**"}, + DefaultCommand: []string{"npm", "start"}, }, "py": { - Types: []string{"py", "toml", "yaml", "yml"}, - ExcludePaths: []string{"__pycache__/**", ".venv/**"}, + Types: []string{"py", "toml", "yaml", "yml"}, + ExcludePaths: []string{"__pycache__/**", ".venv/**"}, + DefaultCommand: []string{"python", "main.py"}, }, } +// ── 入口 ─────────────────────────────────────────────────────────────────────── + func main() { args := os.Args[1:] + args = tryLoadWatchFile(args) + + if len(args) == 0 { + printHelp() + return + } var paths []string var types []string var excludeTypes []string var excludePaths []string var command []string + var clearScreen bool + + // 自动读取 .gitignore 和 .ignore + excludePaths = append(excludePaths, parseIgnoreFiles()...) i := 0 for i < len(args) { arg := args[i] - switch arg { - case "-h", "--help": + switch { + case arg == "-h" || arg == "--help": printHelp() return - case "-path": + case arg == "-c" || arg == "--clear": + clearScreen = true + case arg == "-w" || arg == "--watch": i++ if i < len(args) { paths = append(paths, strings.Split(args[i], ",")...) } - case "-type": + case arg == "-e" || arg == "--ext": i++ if i < len(args) { types = append(types, strings.Split(args[i], ",")...) } - case "-exclude-type": + case arg == "-E" || arg == "--exclude-ext": i++ if i < len(args) { excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...) } - case "-exclude-path": + case arg == "-i" || arg == "--ignore": i++ if i < len(args) { excludePaths = append(excludePaths, args[i]) } - case "-go", "-web", "-js", "-py": - name := strings.TrimPrefix(arg, "-") - if p, ok := presets[name]; ok { - types = append(types, p.Types...) - excludePaths = append(excludePaths, p.ExcludePaths...) + case strings.HasPrefix(arg, "--") && presets[strings.TrimPrefix(arg, "--")].Types != nil: + name := strings.TrimPrefix(arg, "--") + p := presets[name] + types = append(types, p.Types...) + excludePaths = append(excludePaths, p.ExcludePaths...) + if len(command) == 0 { + command = append([]string{}, p.DefaultCommand...) // 设定为预设默认命令 } default: - // 第一个非内置参数,它及其后所有内容作为命令 - command = args[i:] + // 如果走到这里,说明是遇到了位置参数(command 或 参数) + if len(command) > 0 { // 已经有预设的默认命令 + // 判断输入的是否像是一个新命令(例如 `go`, `npm`, `python` 等) + base := args[i] + if idx := strings.LastIndexAny(base, "/\\"); idx >= 0 { + base = base[idx+1:] + } + if base == "go" || base == "go.exe" || base == "npm" || base == "npm.cmd" || base == "python" || base == "python.exe" || base == "node" || base == "node.exe" { + command = args[i:] // 替换为用户显式输入的命令 + } else { + // 否则将其视为预设命令的参数,智能追加 + // 针对 go run . 自动追加 -- 避免参数被 go run 吃掉 + if command[0] == "go" && len(command) >= 3 && command[1] == "run" { + command = append(command, "--") + } + command = append(command, args[i:]...) + } + } else { + command = args[i:] // 没有预设,直接使用 + } i = len(args) continue } @@ -102,84 +167,282 @@ func main() { Debounce: debounceTime, } + printBanner(config, command, clearScreen) + if len(command) == 0 { runMonitor(config) } else { - runCommand(config, command) + runCommand(config, command, clearScreen) } } +// tryLoadWatchFile 尝试从 .watch 读取配置并处理 --task +func tryLoadWatchFile(args []string) []string { + contentBytes, err := os.ReadFile(".watch") + if err != nil { + return args + } + content := string(contentBytes) + + tasks := make(map[string]string) + var firstTask string + + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + k := strings.TrimSpace(parts[0]) + v := strings.TrimSpace(parts[1]) + tasks[k] = v + if firstTask == "" { + firstTask = k + } + } + } + + if len(tasks) == 0 { + return args + } + + targetTask := firstTask // 默认第一个 + if len(args) > 0 && strings.HasPrefix(args[0], "--") { + taskName := strings.TrimPrefix(args[0], "--") + if _, ok := tasks[taskName]; ok && presets[taskName].Types == nil { + targetTask = taskName + args = args[1:] // 消费掉这个参数 + } + } + + if cmdStr, ok := tasks[targetTask]; ok { + fmt.Printf("%s> Using task from .watch: --%s (%s)%s\n", dim, targetTask, cmdStr, reset) + // 简单按空格切割组合 + taskArgs := strings.Fields(cmdStr) + return append(taskArgs, args...) + } + + return args +} + +// parseIgnoreFiles 解析当前目录的忽略文件 +func parseIgnoreFiles() []string { + var ignores []string + files := []string{".gitignore", ".ignore"} + for _, f := range files { + contentBytes, err := os.ReadFile(f) + if err == nil { + content := string(contentBytes) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + ignores = append(ignores, line) + } + } + } + } + return ignores +} + +// ── 监控模式(无命令)────────────────────────────────────────────────────────── + func runMonitor(config watch.Config) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) w, err := watch.Start(config, func(e *watch.Event) { - fmt.Printf("%s: %s\n", e.Type, e.Path) + printEvent(e) }) if err != nil { - fmt.Fprintln(os.Stderr, "watch error:", err) + logError("failed to start watcher: " + err.Error()) os.Exit(1) } defer w.Stop() <-sigCh + fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset) } -func runCommand(config watch.Config, command []string) { +// ── 命令模式 ─────────────────────────────────────────────────────────────────── + +func runCommand(config watch.Config, command []string, clearScreen bool) { execDir, _ := os.Getwd() var proc *shell.Process sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - w, err := watch.Start(config, func(e *watch.Event) { - fmt.Printf("\n>> %s: %s\n", e.Type, e.Path) + restart := func(e *watch.Event) { + if clearScreen { + fmt.Print("\033[2J\033[H") + } + printEvent(e) + printRestart(command) if proc != nil { - proc.Kill() + proc.Kill() // shell.Kill() 已内置完整的进程树(PGID)清理逻辑 } proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) - }) + } + + w, err := watch.Start(config, restart) if err != nil { - fmt.Fprintln(os.Stderr, "watch error:", err) + logError("failed to start watcher: " + err.Error()) os.Exit(1) } defer w.Stop() + // 监听控制台输入 `rs` 手动重启 + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + text := strings.TrimSpace(scanner.Text()) + if text == "rs" { + restart(&watch.Event{Path: "manual restart", Type: watch.Change}) + } + } + }() + // 启动时先执行一次 + if clearScreen { + fmt.Print("\033[2J\033[H") + } + printRestart(command) proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) <-sigCh if proc != nil { proc.Kill() } + fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset) os.Exit(0) } +// ── 输出函数 ─────────────────────────────────────────────────────────────────── + +func printBanner(config watch.Config, command []string, clearScreen bool) { + var parts []string + + parts = append(parts, fmt.Sprintf("%spaths:%s %s", dim, reset, strings.Join(config.Paths, ","))) + + if len(config.Types) > 0 { + types := make([]string, len(config.Types)) + for i, t := range config.Types { + if !strings.HasPrefix(t, ".") { + t = "." + t + } + types[i] = t + } + parts = append(parts, fmt.Sprintf("%sexts:%s %s", dim, reset, strings.Join(types, ","))) + } + + if len(config.ExcludeTypes) > 0 { + exTypes := make([]string, len(config.ExcludeTypes)) + for i, t := range config.ExcludeTypes { + if !strings.HasPrefix(t, ".") { + t = "." + t + } + exTypes[i] = t + } + parts = append(parts, fmt.Sprintf("%s-exts:%s %s", dim, reset, strings.Join(exTypes, ","))) + } + + if len(config.Excludes) > 0 { + showCount := 2 + if len(config.Excludes) > showCount { + parts = append(parts, fmt.Sprintf("%signore:%s %s...", dim, reset, strings.Join(config.Excludes[:showCount], ","))) + } else { + parts = append(parts, fmt.Sprintf("%signore:%s %s", dim, reset, strings.Join(config.Excludes, ","))) + } + } + + if len(command) > 0 { + parts = append(parts, fmt.Sprintf("%scmd:%s %s%s%s", dim, reset, bold, strings.Join(command, " "), reset)) + } + + fmt.Printf("\n%s%swatch%s ❖ %s\n", bold, cyan, reset, strings.Join(parts, fmt.Sprintf(" %s|%s ", dim, reset))) + fmt.Printf("%s(Type `rs` then Enter to restart manually)%s\n\n", gray, reset) +} + +func printEvent(e *watch.Event) { + ts := time.Now().Format("15:04:05") + typeStr, typeColor := eventStyle(e.Type) + + dirMark := "" + if e.IsDir { + dirMark = gray + " [dir]" + reset + } + + fmt.Printf("%s%s%s %s%s%s%s %s%s%s\n", + gray, ts, reset, + bold, typeColor, typeStr, reset, + white, e.Path, reset+dirMark) +} + +func printRestart(command []string) { + ts := time.Now().Format("15:04:05") + cmdStr := strings.Join(command, " ") + line := strings.Repeat("─", 48) + fmt.Printf("\n%s%s%s %s%s$ %s%s%s\n", + gray, line, reset, + gray, ts, reset, + bold+green, cmdStr+reset) +} + +func logError(msg string) { + fmt.Fprintf(os.Stderr, "%s%s✗ %s%s\n", bold, red, msg, reset) +} + +func eventStyle(et watch.EventType) (string, string) { + switch et { + case watch.Create: + return "create", green + case watch.Change: + return "change", blue + case watch.Remove: + return "remove", red + case watch.Rename: + return "rename", yellow + default: + return string(et), white + } +} + +// ── 帮助信息 ─────────────────────────────────────────────────────────────────── + func printHelp() { - fmt.Print(`Usage: watch [options] [command [args...]] + fmt.Print(` +` + bold + bgBlue + ` watch ` + reset + ` File watcher & command runner +` + dim + ` Recursive by default. Respects .gitignore automatically.` + reset + ` -Watch file changes and execute command on change. -Without a command, just print change events to stdout. +` + bold + `Usage` + reset + ` + watch [options] [command [args...]] + watch # execute default task in .watch or print help -Options: - -path Watch paths (comma-separated), default "." - -type Include file types (comma-separated), e.g. -type go,js - -exclude-type Exclude file types (comma-separated) - -exclude-path Exclude path pattern (repeatable, gitignore semantics) +` + bold + `Options` + reset + ` + ` + cyan + `-w, --watch ` + reset + ` Watch paths (comma-separated), default "." + ` + cyan + `-e, --ext ` + reset + ` Include extensions (comma-separated), e.g., ` + yellow + `go,ts` + reset + ` + ` + cyan + `-E, --exclude-ext ` + reset + ` Exclude extensions (comma-separated) + ` + cyan + `-i, --ignore ` + reset + ` Ignore glob patterns (repeatable), e.g., ` + yellow + `node_modules/**` + reset + ` + ` + cyan + `-c, --clear` + reset + ` Clear screen before restarting command + ` + cyan + `-h, --help` + reset + ` Show this help -Presets: - -go Go development (go | vendor/**, **/node_modules/**) - -web Web frontend (html,css,js,ts,vue,jsx,tsx | node_modules/**, dist/**) - -js Node.js development (js,ts,json | node_modules/**) - -py Python development (py,toml,yaml,yml | __pycache__/**, .venv/**) +` + bold + `Presets` + reset + ` (Auto-injects default commands if no command provided) + ` + green + `--go` + reset + ` Go dev — ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go run .` + reset + ` + ` + green + `--gotest` + reset + ` Go test — ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go test -v -count=1 ./...` + reset + ` + ` + green + `--web` + reset + ` Web frontend — ext: ` + dim + `html,css,js,ts,vue,jsx...` + reset + ` | cmd: ` + dim + `npm run dev` + reset + ` + ` + green + `--js` + reset + ` Node.js dev — ext: ` + dim + `js,ts,json` + reset + ` | cmd: ` + dim + `npm start` + reset + ` + ` + green + `--py` + reset + ` Python dev — ext: ` + dim + `py,toml,yaml...` + reset + ` | cmd: ` + dim + `python main.py` + reset + ` -Preset values merge with explicit options; they don't replace them. +` + bold + `Tasks File (.watch)` + reset + ` + You can create a .watch file with key=value format: + ` + dim + `dev = --clear -w src -e go go run .` + reset + ` + ` + dim + `test = --gotest` + reset + ` + Run ` + yellow + `watch --dev` + reset + ` to execute a task, or just ` + yellow + `watch` + reset + ` for the first task. -Examples: - watch -go go test ./... - watch -web npm run build - watch -py python main.py --dev - watch -path ./src -type go go run . - watch # print change events only +` + bold + `Interactive` + reset + ` + Type ` + yellow + `rs` + reset + ` and press Enter while running to force a manual restart. `) } diff --git a/watch_test.go b/watch_test.go index 1e9f2bf..5604a1c 100644 --- a/watch_test.go +++ b/watch_test.go @@ -91,6 +91,52 @@ func TestWatch(t *testing.T) { } } +// TestDirEventWithTypeFilter 验证:配置了 Types 白名单时,文件夹的变化不应触发回调 +func TestDirEventWithTypeFilter(t *testing.T) { + testDir := "test_dir_filter" + _ = os.RemoveAll(testDir) + _ = os.MkdirAll(testDir, 0755) + defer os.RemoveAll(testDir) + + events := make(chan *watch.Event, 5) + config := watch.Config{ + Paths: []string{testDir}, + Types: []string{".go"}, // 只监听 .go 文件 + } + + w, err := watch.Start(config, func(e *watch.Event) { + events <- e + }) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + // 创建一个子目录,不应触发回调 + subDir := filepath.Join(testDir, "subpkg") + _ = os.MkdirAll(subDir, 0755) + + select { + case e := <-events: + t.Errorf("should not receive event for dir creation when Types filter is set, got %v (isDir=%v)", e.Path, e.IsDir) + case <-time.After(150 * time.Millisecond): + // OK: 文件夹创建事件被正确过滤 + } + + // 创建一个 .go 文件,应该触发回调 + goFile, _ := filepath.Abs(filepath.Join(testDir, "main.go")) + _ = os.WriteFile(goFile, []byte("package main"), 0644) + + select { + case e := <-events: + if e.Path != goFile { + t.Errorf("expected event for %s, got %s", goFile, e.Path) + } + case <-time.After(200 * time.Millisecond): + t.Fatal("timeout waiting for .go file create event") + } +} + func TestDebounce(t *testing.T) { testDir := "test_debounce" _ = os.RemoveAll(testDir)