From 272f99e828753d9118c47b788c32296086bda711 Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Mon, 22 Jun 2026 16:49:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(watch):=20=E6=96=B0=E5=A2=9E=20watch=20CLI?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=B7=A5=E5=85=B7=EF=BC=88by=20A?= =?UTF-8?q?I=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 watch/watch 子包,提供 watch 命令,支持 go install 安装 - 支持 -type/-exclude-type/-exclude-path 等 watch.Start 全部配置能力 - 预设 -go/-web/-js/-py 对应各开发场景一键运行 - 预设值与显式参数合并,不替换 - 无命令时打印变更事件到 stdout - 内部 300ms 防抖 + shell.Start 进程管理 Co-Authored-By: glm-5.1 --- CHANGELOG.md | 8 +++ README.md | 38 +++++++++++ TEST.md | 18 ++--- go.mod | 13 ++-- go.sum | 28 ++++---- watch/main.go | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+), 26 deletions(-) create mode 100644 watch/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc1e6d..29e35e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 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 全部配置能力。 + - 预设快捷方式:`-go`/`-web`/`-js`/`-py` 对应各开发场景。 + - 预设值与显式参数合并,不替换。 + - 无命令时打印变更事件到 stdout。 +- **依赖更新**: 新增依赖 `go/shell` v1.5.4,用于子进程生命周期管理。 + ## v1.5.2 (2026-06-21) - **依赖更新**: 升级依赖 `jsmod` 至 `v1.5.3`,`cast` 至 `v1.5.3`,`rand` 至 `v1.5.3`,`encoding` 至 `v1.5.4`,`safe` 至 `v1.5.2`,`file` 至 `v1.5.5`。 diff --git a/README.md b/README.md index 48f347f..27fe63b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,44 @@ go get apigo.cc/go/watch ``` +### CLI 命令行工具 + +```bash +go install apigo.cc/go/watch/watch@latest +``` + +```bash +# Go 开发,变更时重新运行测试 +watch -go go test ./... + +# Web 前端,变更时自动构建 +watch -web npm run build + +# Python 开发,变更时重启脚本 +watch -py python main.py --dev + +# 自定义:监听 src 目录的 Go 文件,变更时运行 +watch -path ./src -type go go run . + +# 仅打印变更事件(不执行命令) +watch -go +``` + +**参数说明**: + +| 参数 | 说明 | +|---|---| +| `-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/\*\*) | + +预设值会与用户显式指定的参数合并追加,不会互相替换。 + ## API 指南 ### Start diff --git a/TEST.md b/TEST.md index e414ac4..a7282d7 100644 --- a/TEST.md +++ b/TEST.md @@ -1,26 +1,26 @@ # Test Report ## 单元测试结果 -执行时间: 2026-06-21 +执行时间: 2026-06-22 ``` === RUN TestWatch ---- PASS: TestWatch (0.20s) +--- PASS: TestWatch (0.21s) === RUN TestDebounce ---- PASS: TestDebounce (0.28s) +--- PASS: TestDebounce (0.26s) === RUN TestEasyStart --- PASS: TestEasyStart (0.00s) PASS -ok apigo.cc/go/watch 2.028s +ok apigo.cc/go/watch 0.852s ``` ## 性能测试结果 (Benchmark) ``` -BenchmarkIsMatch-16 14138798 84.94 ns/op -BenchmarkIsExcluded-16 49184965 23.74 ns/op -BenchmarkDebounce-16 3307248 378.8 ns/op -BenchmarkKeyGeneration-16 27976675 44.53 ns/op -BenchmarkKeyGenerationWithFmt-16 6969997 169.7 ns/op +BenchmarkIsMatch-16 16974895 67.03 ns/op +BenchmarkIsExcluded-16 42681092 24.32 ns/op +BenchmarkDebounce-16 3321357 351.8 ns/op +BenchmarkKeyGeneration-16 27723735 42.48 ns/op +BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op ``` ## 测试覆盖场景 diff --git a/go.mod b/go.mod index e1bbf9f..6a57de3 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,16 @@ require ( github.com/gobwas/glob v0.2.3 ) -require apigo.cc/go/jsmod v1.5.3 +require ( + apigo.cc/go/jsmod v1.5.3 // indirect + apigo.cc/go/shell v1.5.4 +) require ( - apigo.cc/go/cast v1.5.3 - apigo.cc/go/encoding v1.5.4 - apigo.cc/go/rand v1.5.3 - apigo.cc/go/safe v1.5.2 + apigo.cc/go/cast v1.5.3 // indirect + apigo.cc/go/encoding v1.5.4 // indirect + apigo.cc/go/rand v1.5.3 // indirect + apigo.cc/go/safe v1.5.2 // indirect github.com/kr/text v0.2.0 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index 14d8fd7..3d917c0 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,17 @@ -apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE= -apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8= -apigo.cc/go/encoding v1.5.0 h1:EJNdRVDOMoI2DAvZwQNQTbYuqB/6zsEzvg7lS5pQI+I= -apigo.cc/go/encoding v1.5.0/go.mod h1:8++NfZj3hWig0qh2g7GQRw/4LpSvCYMWUZ+8J+x58cA= -apigo.cc/go/file v1.5.0 h1:Fh1NSDBqaxjuXYJ71yPHPXVJ8BFEv/AGS3l+jkLi5uw= -apigo.cc/go/file v1.5.0/go.mod h1:4YhOGgBINTpmmmgws3H8LAyXQQBGzBp44hYUoCS+kr0= -apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec= -apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= -apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E= -apigo.cc/go/rand v1.5.0/go.mod h1:Lh98S2dm9UY0X+M+kNQQEKyXHG5pcCKSFPyXN0QCGdk= -apigo.cc/go/safe v1.5.0 h1:W1NblmcU8cex1f9Y5z8mNLUJOzZTE1s6fszb3FbhGnk= -apigo.cc/go/safe v1.5.0/go.mod h1:OfQ5d6COePSGEuPvMeOk6KagX2sezw7nvKh7exj9SeM= +apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw= +apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= +apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w= +apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM= +apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs= +apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo= +apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= +apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= +apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius= +apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= +apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg= +apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw= +apigo.cc/go/shell v1.5.4 h1:Kn6lP6I6d9U0hbyUjpKKFdFZ8RPo4vi4V6AYW8YFzrc= +apigo.cc/go/shell v1.5.4/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= apigo.cc/go/timer v1.5.0 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk= apigo.cc/go/timer v1.5.0/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -24,7 +26,9 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/watch/main.go b/watch/main.go new file mode 100644 index 0000000..1d9656f --- /dev/null +++ b/watch/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "apigo.cc/go/shell" + "apigo.cc/go/watch" +) + +const debounceTime = 300 * time.Millisecond + +type preset struct { + Types []string + ExcludePaths []string +} + +var presets = map[string]preset{ + "go": { + Types: []string{"go"}, + ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, + }, + "web": { + Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"}, + ExcludePaths: []string{"node_modules/**", "dist/**"}, + }, + "js": { + Types: []string{"js", "ts", "json"}, + ExcludePaths: []string{"node_modules/**"}, + }, + "py": { + Types: []string{"py", "toml", "yaml", "yml"}, + ExcludePaths: []string{"__pycache__/**", ".venv/**"}, + }, +} + +func main() { + args := os.Args[1:] + + var paths []string + var types []string + var excludeTypes []string + var excludePaths []string + var command []string + + i := 0 + for i < len(args) { + arg := args[i] + + switch arg { + case "-h", "--help": + printHelp() + return + case "-path": + i++ + if i < len(args) { + paths = append(paths, strings.Split(args[i], ",")...) + } + case "-type": + i++ + if i < len(args) { + types = append(types, strings.Split(args[i], ",")...) + } + case "-exclude-type": + i++ + if i < len(args) { + excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...) + } + case "-exclude-path": + 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...) + } + default: + // 第一个非内置参数,它及其后所有内容作为命令 + command = args[i:] + i = len(args) + continue + } + i++ + } + + if len(paths) == 0 { + paths = []string{"."} + } + + config := watch.Config{ + Paths: paths, + Types: types, + ExcludeTypes: excludeTypes, + Excludes: excludePaths, + Debounce: debounceTime, + } + + if len(command) == 0 { + runMonitor(config) + } else { + runCommand(config, command) + } +} + +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) + }) + if err != nil { + fmt.Fprintln(os.Stderr, "watch error:", err) + os.Exit(1) + } + defer w.Stop() + + <-sigCh +} + +func runCommand(config watch.Config, command []string) { + 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) + if proc != nil { + proc.Kill() + } + proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) + }) + if err != nil { + fmt.Fprintln(os.Stderr, "watch error:", err) + os.Exit(1) + } + defer w.Stop() + + // 启动时先执行一次 + proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) + + <-sigCh + if proc != nil { + proc.Kill() + } + os.Exit(0) +} + +func printHelp() { + fmt.Print(`Usage: watch [options] [command [args...]] + +Watch file changes and execute command on change. +Without a command, just print change events to stdout. + +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) + +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/**) + +Preset values merge with explicit options; they don't replace them. + +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 +`) +}