Compare commits
No commits in common. "main" and "v1.5.2" have entirely different histories.
20
CHANGELOG.md
20
CHANGELOG.md
@ -1,25 +1,5 @@
|
|||||||
# CHANGELOG
|
# 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 全部配置能力。
|
|
||||||
- 预设快捷方式:`-go`/`-web`/`-js`/`-py` 对应各开发场景。
|
|
||||||
- 预设值与显式参数合并,不替换。
|
|
||||||
- 无命令时打印变更事件到 stdout。
|
|
||||||
- **依赖更新**: 新增依赖 `go/shell` v1.5.4,用于子进程生命周期管理。
|
|
||||||
|
|
||||||
## v1.5.2 (2026-06-21)
|
## 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`。
|
- **依赖更新**: 升级依赖 `jsmod` 至 `v1.5.3`,`cast` 至 `v1.5.3`,`rand` 至 `v1.5.3`,`encoding` 至 `v1.5.4`,`safe` 至 `v1.5.2`,`file` 至 `v1.5.5`。
|
||||||
|
|
||||||
|
|||||||
55
README.md
55
README.md
@ -20,61 +20,6 @@
|
|||||||
go get apigo.cc/go/watch
|
go get apigo.cc/go/watch
|
||||||
```
|
```
|
||||||
|
|
||||||
### CLI 命令行工具
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go install apigo.cc/go/watch/watch@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**快速体验**:
|
|
||||||
```bash
|
|
||||||
# Go 开发:无需提供命令,默认自动执行 go run . (并支持智能追加参数)
|
|
||||||
watch --go
|
|
||||||
watch --go --dev # 实际执行: go run . -- --dev
|
|
||||||
|
|
||||||
# Go 测试:默认执行 go test -v -count=1 ./...
|
|
||||||
watch --gotest
|
|
||||||
|
|
||||||
# Web 前端:默认执行 npm run dev
|
|
||||||
watch --web
|
|
||||||
|
|
||||||
# 自定义执行命令(清屏并编译)
|
|
||||||
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` 并回车,即可强行手动重启。
|
|
||||||
|
|
||||||
**参数说明**:
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `-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 指南
|
## API 指南
|
||||||
|
|
||||||
### Start
|
### Start
|
||||||
|
|||||||
21
TEST.md
21
TEST.md
@ -1,28 +1,26 @@
|
|||||||
# Test Report
|
# Test Report
|
||||||
|
|
||||||
## 单元测试结果
|
## 单元测试结果
|
||||||
执行时间: 2026-06-27
|
执行时间: 2026-06-21
|
||||||
|
|
||||||
```
|
```
|
||||||
=== RUN TestWatch
|
=== RUN TestWatch
|
||||||
--- PASS: TestWatch (0.21s)
|
--- PASS: TestWatch (0.20s)
|
||||||
=== RUN TestDirEventWithTypeFilter
|
|
||||||
--- PASS: TestDirEventWithTypeFilter (0.15s)
|
|
||||||
=== RUN TestDebounce
|
=== RUN TestDebounce
|
||||||
--- PASS: TestDebounce (0.26s)
|
--- PASS: TestDebounce (0.28s)
|
||||||
=== RUN TestEasyStart
|
=== RUN TestEasyStart
|
||||||
--- PASS: TestEasyStart (0.00s)
|
--- PASS: TestEasyStart (0.00s)
|
||||||
PASS
|
PASS
|
||||||
ok apigo.cc/go/watch 1.018s
|
ok apigo.cc/go/watch 2.028s
|
||||||
```
|
```
|
||||||
|
|
||||||
## 性能测试结果 (Benchmark)
|
## 性能测试结果 (Benchmark)
|
||||||
```
|
```
|
||||||
BenchmarkIsMatch-16 16974895 67.03 ns/op
|
BenchmarkIsMatch-16 14138798 84.94 ns/op
|
||||||
BenchmarkIsExcluded-16 42681092 24.32 ns/op
|
BenchmarkIsExcluded-16 49184965 23.74 ns/op
|
||||||
BenchmarkDebounce-16 3321357 351.8 ns/op
|
BenchmarkDebounce-16 3307248 378.8 ns/op
|
||||||
BenchmarkKeyGeneration-16 27723735 42.48 ns/op
|
BenchmarkKeyGeneration-16 27976675 44.53 ns/op
|
||||||
BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op
|
BenchmarkKeyGenerationWithFmt-16 6969997 169.7 ns/op
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试覆盖场景
|
## 测试覆盖场景
|
||||||
@ -35,4 +33,3 @@ BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op
|
|||||||
4. **路径归一化**: 验证绝对路径下的准确匹配。
|
4. **路径归一化**: 验证绝对路径下的准确匹配。
|
||||||
5. **极简接口**: 验证 `EasyStart` 的可用性。
|
5. **极简接口**: 验证 `EasyStart` 的可用性。
|
||||||
6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。
|
6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。
|
||||||
7. **类型白名单与目录过滤**: 验证开启 Types 白名单时,正常过滤目录变更事件,不误触发回调 (`TestDirEventWithTypeFilter`)。
|
|
||||||
|
|||||||
13
go.mod
13
go.mod
@ -9,16 +9,13 @@ require (
|
|||||||
github.com/gobwas/glob v0.2.3
|
github.com/gobwas/glob v0.2.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require apigo.cc/go/jsmod v1.5.3
|
||||||
apigo.cc/go/jsmod v1.5.3 // indirect
|
|
||||||
apigo.cc/go/shell v1.5.4
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/cast v1.5.3 // indirect
|
apigo.cc/go/cast v1.5.3
|
||||||
apigo.cc/go/encoding v1.5.4 // indirect
|
apigo.cc/go/encoding v1.5.4
|
||||||
apigo.cc/go/rand v1.5.3 // indirect
|
apigo.cc/go/rand v1.5.3
|
||||||
apigo.cc/go/safe v1.5.2 // indirect
|
apigo.cc/go/safe v1.5.2
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
|||||||
28
go.sum
28
go.sum
@ -1,17 +1,15 @@
|
|||||||
apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw=
|
apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE=
|
||||||
apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4=
|
apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8=
|
||||||
apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w=
|
apigo.cc/go/encoding v1.5.0 h1:EJNdRVDOMoI2DAvZwQNQTbYuqB/6zsEzvg7lS5pQI+I=
|
||||||
apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM=
|
apigo.cc/go/encoding v1.5.0/go.mod h1:8++NfZj3hWig0qh2g7GQRw/4LpSvCYMWUZ+8J+x58cA=
|
||||||
apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs=
|
apigo.cc/go/file v1.5.0 h1:Fh1NSDBqaxjuXYJ71yPHPXVJ8BFEv/AGS3l+jkLi5uw=
|
||||||
apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo=
|
apigo.cc/go/file v1.5.0/go.mod h1:4YhOGgBINTpmmmgws3H8LAyXQQBGzBp44hYUoCS+kr0=
|
||||||
apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU=
|
apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec=
|
||||||
apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
|
apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
|
||||||
apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius=
|
apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E=
|
||||||
apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4=
|
apigo.cc/go/rand v1.5.0/go.mod h1:Lh98S2dm9UY0X+M+kNQQEKyXHG5pcCKSFPyXN0QCGdk=
|
||||||
apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg=
|
apigo.cc/go/safe v1.5.0 h1:W1NblmcU8cex1f9Y5z8mNLUJOzZTE1s6fszb3FbhGnk=
|
||||||
apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw=
|
apigo.cc/go/safe v1.5.0/go.mod h1:OfQ5d6COePSGEuPvMeOk6KagX2sezw7nvKh7exj9SeM=
|
||||||
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 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk=
|
||||||
apigo.cc/go/timer v1.5.0/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs=
|
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=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
@ -26,9 +24,7 @@ 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 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 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
9
watch.go
9
watch.go
@ -251,13 +251,8 @@ func (watcher *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 类型过滤
|
// 3. 类型过滤 (仅针对文件)
|
||||||
if isDir {
|
if !isDir {
|
||||||
// 目录没有后缀,若用户配置了文件类型白名单,则目录事件不匹配
|
|
||||||
if len(watcher.typeMap) > 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ext := filepath.Ext(path)
|
ext := filepath.Ext(path)
|
||||||
if len(watcher.exTypeMap) > 0 {
|
if len(watcher.exTypeMap) > 0 {
|
||||||
if _, ok := watcher.exTypeMap[ext]; ok {
|
if _, ok := watcher.exTypeMap[ext]; ok {
|
||||||
|
|||||||
448
watch/main.go
448
watch/main.go
@ -1,448 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"apigo.cc/go/shell"
|
|
||||||
"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
|
|
||||||
DefaultCommand []string
|
|
||||||
}
|
|
||||||
|
|
||||||
var presets = map[string]preset{
|
|
||||||
"go": {
|
|
||||||
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/**"},
|
|
||||||
DefaultCommand: []string{"npm", "run", "dev"},
|
|
||||||
},
|
|
||||||
"js": {
|
|
||||||
Types: []string{"js", "ts", "json"},
|
|
||||||
ExcludePaths: []string{"node_modules/**"},
|
|
||||||
DefaultCommand: []string{"npm", "start"},
|
|
||||||
},
|
|
||||||
"py": {
|
|
||||||
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 {
|
|
||||||
case arg == "-h" || arg == "--help":
|
|
||||||
printHelp()
|
|
||||||
return
|
|
||||||
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 arg == "-e" || arg == "--ext":
|
|
||||||
i++
|
|
||||||
if i < len(args) {
|
|
||||||
types = append(types, strings.Split(args[i], ",")...)
|
|
||||||
}
|
|
||||||
case arg == "-E" || arg == "--exclude-ext":
|
|
||||||
i++
|
|
||||||
if i < len(args) {
|
|
||||||
excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...)
|
|
||||||
}
|
|
||||||
case arg == "-i" || arg == "--ignore":
|
|
||||||
i++
|
|
||||||
if i < len(args) {
|
|
||||||
excludePaths = append(excludePaths, args[i])
|
|
||||||
}
|
|
||||||
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 或 参数)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(paths) == 0 {
|
|
||||||
paths = []string{"."}
|
|
||||||
}
|
|
||||||
|
|
||||||
config := watch.Config{
|
|
||||||
Paths: paths,
|
|
||||||
Types: types,
|
|
||||||
ExcludeTypes: excludeTypes,
|
|
||||||
Excludes: excludePaths,
|
|
||||||
Debounce: debounceTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
printBanner(config, command, clearScreen)
|
|
||||||
|
|
||||||
if len(command) == 0 {
|
|
||||||
runMonitor(config)
|
|
||||||
} else {
|
|
||||||
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) {
|
|
||||||
printEvent(e)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
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, clearScreen bool) {
|
|
||||||
execDir, _ := os.Getwd()
|
|
||||||
|
|
||||||
var proc *shell.Process
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
||||||
|
|
||||||
restart := func(e *watch.Event) {
|
|
||||||
if clearScreen {
|
|
||||||
fmt.Print("\033[2J\033[H")
|
|
||||||
}
|
|
||||||
printEvent(e)
|
|
||||||
printRestart(command)
|
|
||||||
if proc != nil {
|
|
||||||
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 {
|
|
||||||
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(`
|
|
||||||
` + bold + bgBlue + ` watch ` + reset + ` File watcher & command runner
|
|
||||||
` + dim + ` Recursive by default. Respects .gitignore automatically.` + reset + `
|
|
||||||
|
|
||||||
` + bold + `Usage` + reset + `
|
|
||||||
watch [options] [command [args...]]
|
|
||||||
watch # execute default task in .watch or print help
|
|
||||||
|
|
||||||
` + bold + `Options` + reset + `
|
|
||||||
` + cyan + `-w, --watch <path>` + reset + ` Watch paths (comma-separated), default "."
|
|
||||||
` + cyan + `-e, --ext <exts>` + reset + ` Include extensions (comma-separated), e.g., ` + yellow + `go,ts` + reset + `
|
|
||||||
` + cyan + `-E, --exclude-ext <exts>` + reset + ` Exclude extensions (comma-separated)
|
|
||||||
` + cyan + `-i, --ignore <pattern>` + 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
|
|
||||||
|
|
||||||
` + 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 + `
|
|
||||||
|
|
||||||
` + 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.
|
|
||||||
|
|
||||||
` + bold + `Interactive` + reset + `
|
|
||||||
Type ` + yellow + `rs` + reset + ` and press Enter while running to force a manual restart.
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
@ -91,52 +91,6 @@ 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) {
|
func TestDebounce(t *testing.T) {
|
||||||
testDir := "test_debounce"
|
testDir := "test_debounce"
|
||||||
_ = os.RemoveAll(testDir)
|
_ = os.RemoveAll(testDir)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user