feat(watch): 新增 watch CLI 命令行工具(by AI)

- 新增 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 <zai-org@claude-code-best.win>
This commit is contained in:
AI Engineer 2026-06-22 16:49:27 +08:00
parent ee993012a9
commit 272f99e828
6 changed files with 264 additions and 26 deletions

View File

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

View File

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

18
TEST.md
View File

@ -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
```
## 测试覆盖场景

13
go.mod
View File

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

28
go.sum
View File

@ -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=

185
watch/main.go Normal file
View File

@ -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 <paths> Watch paths (comma-separated), default "."
-type <types> Include file types (comma-separated), e.g. -type go,js
-exclude-type <types> Exclude file types (comma-separated)
-exclude-path <pattern> 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
`)
}