5 Commits
v0.0.3 ... main

Author SHA1 Message Date
Zoe
dcd8d810f0 More robust script handling and code cleanup
In this commit, I have made the script handling dramatically more
robust, and the code has been cleaned up a fair amount. We no longer
have to manually set is restarting or anything like that, and we dont
rely on a fragile exit code system.
2025-10-07 16:15:05 +00:00
Zoe
4a70260ea5 Better debugging, and several bug fixes
This commit brings a greater experience for developers working on ZQDGR,
and also fixes a number of bugs. The following bugs have been fixed:
- Scripts that died in watch mode did not restart once changes were made
- Created and deleted files did not cause a reload
- script.Stop is now used in every place where we kill the process, this
  ensures that the process is *eventually* actually killed
2025-10-06 15:54:31 -05:00
Zoe
a62bfdb01d Fix restart after program has died 2025-10-06 14:43:33 +00:00
Zoe
f52a61b9ea ZQDGR v0.0.4: Qualirt of Life Update
This update brings several quality of life enhancements:
- You can now use excluded_files to exclude files from the watcher
- ZQDGR reloads itself when its config changes
- ZQDGR will not start if there are unrecognized keys in the config

It also fixes a bug where if a program was sent a kill signal, but didnt
die, ZQDGR would carry on as if it did, which lead to many issues.
2025-10-03 02:00:22 -05:00
Zoe
adac21ce29 revert flattening (#3), add -C 2025-05-22 15:52:34 -05:00
4 changed files with 435 additions and 250 deletions

View File

@@ -14,44 +14,64 @@ go install github.com/juls0730/zqdgr@latest
## Usage ## Usage
Full usage Full usage
```Bash ```Bash
zqdgr [options] <command> zqdgr [options] <command>
``` ```
The list of commands is ### Commands
- `init` - `init`
generates a zqdgr.config.json file in the current directory generates a zqdgr.config.json file in the current directory
- `new [project name] [github repo]` - `new [project name] [github repo]`
Creates a new golang project with zqdgr and can optionally run scripts from a github repo Creates a new golang project with zqdgr and can optionally run scripts from a github repo
- `watch <script>` - `watch <script>`
runs the script in "watch mode", when files that follow the pattern in zqdgr.config.json change, the script restarts runs the script in "watch mode", when files that follow the pattern in zqdgr.config.json change, the script restarts
- `<script>` - `<script>`
runs the script runs the script
### Options
ZQDGR has the following list of options
- `-no-ws` - `-no-ws`
disables the web socket server running at 2067 disables the web socket server running at 2067
- `-config`
specifies the path to the config file
- `-disable-reload-config`
disables reloading the config file when it changes
- `-C`
specifies the path to use as the working directry
Example usage: Example usage:
```bash ```bash
zqdgr init zqdgr init
zqdgr watch dev zqdgr watch dev
zqdgr -config ./my-config.json watch dev
zqdgr -C ./my-nested-project watch dev
``` ```
### ZQDGR websocket ### ZQDGR websocket
ZQDGR comes with a websocket to notify listeners that the application has updates, the websocket is accessible at ZQDGR comes with a websocket to notify listeners that the application has updates, the websocket is accessible at
`127.0.0.1:2067/ws`. An example dev script to listen for rebuilds might look like this `127.0.0.1:2067/ws`. An example dev script to listen for rebuilds might look like this
```Javascript ```Javascript
let host = window.location.hostname; let host = window.location.hostname;
const socket = new WebSocket('ws://' + host + ':2067/ws'); const socket = new WebSocket('ws://' + host + ':2067/ws');
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
if (event.data === 'refresh') { if (event.data === 'refresh') {
@@ -75,21 +95,22 @@ socket.addEventListener('message', (event) => {
ZQDGR is solely configured by a `zqdgr.config.json` file in the root of your project. The file has the following keys: ZQDGR is solely configured by a `zqdgr.config.json` file in the root of your project. The file has the following keys:
| Key | Type | Description | | Key | Type | Required | default | Description |
| --- | --- | --- | | ---------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------------------------------- |
| name | string | The name of the project | | name | string | - | - | The name of the project |
| version | string | The version of the project | | version | string | - | - | The version of the project |
| description | string | The description of the project | | description | string | - | - | The description of the project |
| author | string | The author of the project (probably you) | | author | string | - | - | The author of the project (probably you) |
| license | string | The license of the project | | license | string | - | - | The license of the project |
| homepage | string | The URL to the homepage of the project | | homepage | string | - | - | The URL to the homepage of the project |
| repository | object | The repository of the project | | repository | object | - | - | The repository of the project |
| repository.type | string | The type of VCS that you use, most likely git | | repository.type | string | - | - | The type of VCS that you use, most likely git |
| repository.url | string | The place where you code is hosted. This should be a URI that can be used as is in a program such as git | | repository.url | string | - | - | The place where you code is hosted. This should be a URI that can be used as is in a program such as git |
| scripts | object | An object that maps a script name to a script command, which is just a shell command | | scripts | object | true | - | An object that maps a script name to a script command, which is just a shell command |
| pattern | string | The GLOB pattern that ZQDGR will watch for changes | | pattern | string | true | - | The GLOB pattern that ZQDGR will watch for changes |
| excluded_dirs | array | The directories that ZQDGR will ignore when in the `watch` mode | | excluded_globs | array | - | - | Globs that ZQDGR will ignore when in the `watch` mode |
| shutdown_signal | string | The signal that ZQDGR will use to shutdown the script. Currently the only supported signals are `SIGINT`, `SIGTERM`, and `SIGQUIT`, if no shutdown signal is specified, ZQDGR will default to `SIGKILL` | | shutdown_signal | string | - | SIGINT | The signal that ZQDGR will use to shutdown the script. One of SIGKILL, SIGINT, SIGQUIT, SIGTERM |
| shutdown_timeout | int | - | 1 | The amount of time in seconds that ZQDGR will wait for the script to exit before force killing it. |
The only required key is `scripts`, the rest are optional, but we recommend you set the most important ones. The only required key is `scripts`, the rest are optional, but we recommend you set the most important ones.

547
main.go
View File

@@ -2,20 +2,21 @@ package main
import ( import (
"bufio" "bufio"
"bytes"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"sort" "strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -25,8 +26,6 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
var executableName string
//go:embed embed/zqdgr.config.json //go:embed embed/zqdgr.config.json
var zqdgrConfig []byte var zqdgrConfig []byte
@@ -41,76 +40,66 @@ type Config struct {
Type string `json:"type"` Type string `json:"type"`
URL string `json:"url"` URL string `json:"url"`
} `json:"repository"` } `json:"repository"`
Scripts map[string]string `json:"scripts"` Scripts map[string]string `json:"scripts"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
ExcludedDirs []string `json:"excluded_dirs"` // Deprecated: use excludedGlobs instead
ShutdownSignal string `json:"shutdown_signal"` ExcludedDirs []string `json:"excluded_dirs"`
ExcludedGlobs []string `json:"excluded_files"`
ShutdownSignal string `json:"shutdown_signal"`
ShutdownTimeout int `json:"shutdown_timeout"`
} }
type Script struct { type Script struct {
command *exec.Cmd zqdgr *ZQDGR
mutex sync.Mutex command *exec.Cmd
scriptName string mutex sync.Mutex
isRestarting bool scriptName string
wg sync.WaitGroup // notified with the exit code of the script when it exits
// the exit code of the script, only set after the script has exited exitCode chan int
exitCode int
} }
func flattenZQDGRScript(commandString string) string { type ZQDGR struct {
keys := make([]string, 0, len(config.Scripts)) Config Config
for k := range config.Scripts { WorkingDirectory string
keys = append(keys, k) EnableWebSocket bool
} WSServer *WSServer
// Sort the keys in descending order in order to prevent scripts that might be substrings of other scripts to
// evaluate first.
sort.Slice(keys, func(i, j int) bool {
return len(keys[i]) > len(keys[j])
})
// escape scripts to be evaluated via regex
escapedKeys := make([]string, len(keys))
for i, key := range keys {
escapedKeys[i] = regexp.QuoteMeta(key)
}
pattern := `\b(` + executableName + `)\b` + `\s+` + `\b(` + strings.Join(escapedKeys, "|") + `)\b`
re := regexp.MustCompile(pattern)
currentCommand := commandString
for {
previousCommand := currentCommand
currentCommand = re.ReplaceAllStringFunc(currentCommand, func(match string) string {
// match the script name, not the whole `zqdgr script` command
match = strings.Split(match, " ")[1]
if val, ok := config.Scripts[match]; ok {
return val
}
return match
})
// If the current command has not changed, we have completely evaluated the command.
if currentCommand == previousCommand {
break
}
}
if re.MatchString(currentCommand) {
fmt.Println("Error: circular dependency detected in scripts")
os.Exit(1)
}
return currentCommand
} }
func NewCommand(scriptName string, args ...string) *exec.Cmd { type WSServer struct {
if script, ok := config.Scripts[scriptName]; ok { upgrader websocket.Upgrader
clients map[*websocket.Conn]bool
clientsMux sync.Mutex
}
func NewZQDGR(enableWebSocket bool, configDir string) *ZQDGR {
zqdgr := &ZQDGR{
WorkingDirectory: configDir,
}
err := zqdgr.loadConfig()
if err != nil {
log.Fatal(err)
return nil
}
zqdgr.EnableWebSocket = enableWebSocket
zqdgr.WSServer = &WSServer{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
},
clients: make(map[*websocket.Conn]bool),
clientsMux: sync.Mutex{},
}
return zqdgr
}
func (zqdgr *ZQDGR) NewCommand(scriptName string, args ...string) *exec.Cmd {
if script, ok := zqdgr.Config.Scripts[scriptName]; ok {
fullCmd := strings.Join(append([]string{script}, args...), " ") fullCmd := strings.Join(append([]string{script}, args...), " ")
fullCmd = flattenZQDGRScript(fullCmd)
var cmd *exec.Cmd var cmd *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", fullCmd) cmd = exec.Command("cmd", "/C", fullCmd)
@@ -118,6 +107,8 @@ func NewCommand(scriptName string, args ...string) *exec.Cmd {
cmd = exec.Command("sh", "-c", fullCmd) cmd = exec.Command("sh", "-c", fullCmd)
} }
cmd.Dir = zqdgr.WorkingDirectory
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, Setpgid: true,
} }
@@ -132,8 +123,8 @@ func NewCommand(scriptName string, args ...string) *exec.Cmd {
} }
} }
func NewScript(scriptName string, args ...string) *Script { func (zqdgr *ZQDGR) NewScript(scriptName string, args ...string) *Script {
command := NewCommand(scriptName, args...) command := zqdgr.NewCommand(scriptName, args...)
if command == nil { if command == nil {
log.Fatal("script not found") log.Fatal("script not found")
@@ -141,9 +132,10 @@ func NewScript(scriptName string, args ...string) *Script {
} }
return &Script{ return &Script{
command: command, zqdgr: zqdgr,
scriptName: scriptName, command: command,
isRestarting: false, scriptName: scriptName,
exitCode: make(chan int),
} }
} }
@@ -151,58 +143,97 @@ func (s *Script) Start() error {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
s.wg.Add(1)
err := s.command.Start() err := s.command.Start()
if err != nil { if err != nil {
s.wg.Done()
return err return err
} }
go func() { go func() {
err := s.command.Wait() processState, err := s.command.Process.Wait()
if err != nil { slog.Debug("Script exited", "script", s.scriptName, "error", err)
if exitError, ok := err.(*exec.ExitError); ok {
s.exitCode = exitError.ExitCode()
} else {
// Other errors (e.g., process not found, permission denied)
log.Printf("Error waiting for script %s: %v", s.scriptName, err)
s.exitCode = 1
}
}
if !s.isRestarting { s.exitCode <- processState.ExitCode()
s.wg.Done()
}
}() }()
return err return err
} }
func (s *Script) Restart() error { // it is the caller's responsibility to lock the mutex before calling this function
s.mutex.Lock() func (s *Script) Stop() error {
slog.Debug("Making sure process is still alive")
s.isRestarting = true if runtime.GOOS == "windows" {
if _, err := os.FindProcess(s.command.Process.Pid); err != nil {
if s.command.Process != nil { // process is already dead
var signal syscall.Signal return nil
switch config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
} }
} else {
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil { process, err := os.FindProcess(s.command.Process.Pid)
log.Printf("error killing previous process: %v", err) if err != nil {
return err
}
// Sending signal 0 checks for existence and permissions
err = process.Signal(syscall.Signal(0))
if err != nil {
return nil
} }
} }
s.command = NewCommand(s.scriptName) slog.Debug("Process is still alive, sending signal")
dead := make(chan bool)
go func() {
s.command.Wait()
dead <- true
}()
var signal syscall.Signal
switch s.zqdgr.Config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil {
log.Printf("error killing previous process: %v", err)
return err
}
shutdownTimeout := s.zqdgr.Config.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 1
}
select {
case <-dead:
case <-time.After(time.Duration(shutdownTimeout) * time.Second):
log.Println("Script failed to stop after kill signal, force killing")
if err := syscall.Kill(-s.command.Process.Pid, syscall.SIGKILL); err != nil {
log.Printf("error killing previous process: %v", err)
return err
}
}
return nil
}
func (s *Script) Restart() error {
slog.Debug("Restarting script", "script", s.scriptName)
s.mutex.Lock()
err := s.Stop()
if err != nil {
s.mutex.Unlock()
return err
}
s.command = s.zqdgr.NewCommand(s.scriptName)
if s.command == nil { if s.command == nil {
// this should never happen // this should never happen
@@ -210,76 +241,60 @@ func (s *Script) Restart() error {
return nil return nil
} }
s.isRestarting = false
s.mutex.Unlock() s.mutex.Unlock()
err := s.Start() err = s.Start()
// tell the websocket clients to refresh // tell the websocket clients to refresh
if enableWebSocket { if s.zqdgr.EnableWebSocket {
clientsMux.Lock() s.zqdgr.WSServer.clientsMux.Lock()
for client := range clients { for client := range s.zqdgr.WSServer.clients {
err := client.WriteMessage(websocket.TextMessage, []byte("refresh")) err := client.WriteMessage(websocket.TextMessage, []byte("refresh"))
if err != nil { if err != nil {
log.Printf("error broadcasting refresh: %v", err) log.Printf("error broadcasting refresh: %v", err)
client.Close() client.Close()
delete(clients, client) delete(s.zqdgr.WSServer.clients, client)
} }
} }
clientsMux.Unlock() s.zqdgr.WSServer.clientsMux.Unlock()
} }
return err return err
} }
func (s *Script) Wait() { func (wsServer *WSServer) handleWs(w http.ResponseWriter, r *http.Request) {
s.wg.Wait() conn, err := wsServer.upgrader.Upgrade(w, r, nil)
}
func handleWs(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("error upgrading connection: %v", err) log.Printf("error upgrading connection: %v", err)
return return
} }
clientsMux.Lock() wsServer.clientsMux.Lock()
clients[conn] = true wsServer.clients[conn] = true
clientsMux.Unlock() wsServer.clientsMux.Unlock()
for { for {
_, _, err := conn.ReadMessage() _, _, err := conn.ReadMessage()
if err != nil { if err != nil {
clientsMux.Lock() wsServer.clientsMux.Lock()
delete(clients, conn) delete(wsServer.clients, conn)
clientsMux.Unlock() wsServer.clientsMux.Unlock()
break break
} }
} }
} }
var ( func (zqdgr *ZQDGR) loadConfig() error {
enableWebSocket = false data, err := os.ReadFile(path.Join(zqdgr.WorkingDirectory, "zqdgr.config.json"))
config Config
script *Script
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
clients = make(map[*websocket.Conn]bool)
clientsMux sync.Mutex
)
func loadConfig() error {
data, err := os.ReadFile("zqdgr.config.json")
if err == nil { if err == nil {
if err := json.Unmarshal(data, &config); err != nil { decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err := decoder.Decode(&zqdgr.Config)
if err != nil {
return fmt.Errorf("error parsing config file: %v", err) return fmt.Errorf("error parsing config file: %v", err)
} }
} else { } else {
config = Config{ zqdgr.Config = Config{
Scripts: map[string]string{ Scripts: map[string]string{
"build": "go build", "build": "go build",
"run": "go run main.go", "run": "go run main.go",
@@ -288,27 +303,100 @@ func loadConfig() error {
} }
} }
if zqdgr.Config.ExcludedDirs != nil {
fmt.Printf("WARNING: the 'excluded_dirs' key is deprecated, please use 'excluded_globs' instead\n")
zqdgr.Config.ExcludedGlobs = append(zqdgr.Config.ExcludedGlobs, zqdgr.Config.ExcludedDirs...)
}
return nil return nil
} }
func validatePattern(pattern string) ([]string, error) {
var paternArray []string
var currentPattern string
inMatch := false
// iterate over every letter in the pattern
for _, p := range pattern {
if string(p) == "{" {
if inMatch {
return nil, fmt.Errorf("unexpected { in pattern")
}
inMatch = true
}
if string(p) == "}" {
if !inMatch {
return nil, fmt.Errorf("enexpected } in pattern")
}
inMatch = false
}
if string(p) == "," && !inMatch {
paternArray = append(paternArray, currentPattern)
currentPattern = ""
continue
}
currentPattern += string(p)
}
if inMatch {
return nil, fmt.Errorf("unmatched } in pattern")
}
if currentPattern != "" {
paternArray = append(paternArray, currentPattern)
}
return paternArray, nil
}
func main() { func main() {
// var err error
var debugMode bool
noWs := flag.Bool("no-ws", false, "Disable WebSocket server") noWs := flag.Bool("no-ws", false, "Disable WebSocket server")
configDir := flag.String("config", ".", "Path to the config directory")
flag.StringVar(configDir, "C", *configDir, "Path to the config directory")
disableReloadConfig := flag.Bool("no-reload-config", false, "Do not restart ZQDGR on config file change")
flag.Parse() flag.Parse()
if err := loadConfig(); err != nil { // if ParseBool returns an error, ZQDGR_DEBUG is not a bool
debugMode, _ = strconv.ParseBool(os.Getenv("ZQDGR_DEBUG"))
if debugMode {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
originalArgs := os.Args
os.Args = flag.Args()
expandedConfigDir, err := filepath.Abs(*configDir)
if err != nil {
log.Fatal(err) log.Fatal(err)
} }
slog.Debug("noWS", "noWs", *noWs, "configDir", *configDir, "expandedConfigDir", expandedConfigDir, "disableReloadConfig", *disableReloadConfig)
zqdgr := NewZQDGR(*noWs, expandedConfigDir)
if zqdgr == nil {
return
}
var command string var command string
var commandArgs []string var commandArgs []string
// get the name of the executable, and if it's a path then get the base name // command name trimmed by flags.Args()
// this is mainly for testing // os.Args ~= [script, --, arguments]
executableName = path.Base(os.Args[0]) for i, arg := range os.Args {
for i, arg := range os.Args[1:] {
if arg == "--" { if arg == "--" {
commandArgs = os.Args[i+2:] slog.Debug("Found double-dash", "i", i, "len(os.Args)", len(os.Args), "os.Args", os.Args)
if len(os.Args)-i > 1 {
commandArgs = os.Args[i+1:]
}
break break
} }
@@ -320,6 +408,8 @@ func main() {
commandArgs = append(commandArgs, arg) commandArgs = append(commandArgs, arg)
} }
slog.Debug("Collected", "command", command, "commandArgs", commandArgs)
watchMode := false watchMode := false
var scriptName string var scriptName string
switch command { switch command {
@@ -462,18 +552,12 @@ func main() {
log.Fatal("please specify a script to run") log.Fatal("please specify a script to run")
} }
watchMode = true watchMode = true
for i := 0; i < len(commandArgs); i++ { scriptName = commandArgs[0]
if strings.HasPrefix(commandArgs[i], "-") {
continue
}
scriptName = commandArgs[i]
}
default: default:
scriptName = command scriptName = command
} }
script = NewScript(scriptName, commandArgs...) script := zqdgr.NewScript(scriptName, commandArgs...)
if err := script.Start(); err != nil { if err := script.Start(); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -486,19 +570,9 @@ func main() {
log.Println("Received signal, exiting...") log.Println("Received signal, exiting...")
if script.command != nil { if script.command != nil {
var signal syscall.Signal script.mutex.Lock()
switch config.ShutdownSignal { script.Stop()
case "SIGINT": script.mutex.Unlock()
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
syscall.Kill(-script.command.Process.Pid, signal)
} }
os.Exit(0) os.Exit(0)
@@ -506,10 +580,10 @@ func main() {
if watchMode { if watchMode {
if !*noWs { if !*noWs {
enableWebSocket = true zqdgr.EnableWebSocket = true
go func() { go func() {
http.HandleFunc("/ws", handleWs) http.HandleFunc("/ws", zqdgr.WSServer.handleWs)
log.Printf("WebSocket server running on :2067") log.Printf("WebSocket server running on :2067")
if err := http.ListenAndServe(":2067", nil); err != nil { if err := http.ListenAndServe(":2067", nil); err != nil {
log.Printf("WebSocket server error: %v", err) log.Printf("WebSocket server error: %v", err)
@@ -517,52 +591,26 @@ func main() {
}() }()
} }
if config.Pattern == "" { if zqdgr.Config.Pattern == "" {
log.Fatal("watch pattern not specified in config") log.Fatal("watch pattern not specified in config")
} }
var paternArray []string // make sure the pattern is valid
var currentPattern string patternArray, err := validatePattern(zqdgr.Config.Pattern)
inMatch := false if err != nil {
// iterate over every letter in the pattern log.Fatal(err)
for _, p := range config.Pattern {
if string(p) == "{" {
if inMatch {
log.Fatal("unmatched { in pattern")
}
inMatch = true
}
if string(p) == "}" {
if !inMatch {
log.Fatal("unmatched } in pattern")
}
inMatch = false
}
if string(p) == "," && !inMatch {
paternArray = append(paternArray, currentPattern)
currentPattern = ""
inMatch = false
continue
}
currentPattern += string(p)
} }
if inMatch { for _, pattern := range zqdgr.Config.ExcludedGlobs {
log.Fatal("unmatched } in pattern") _, err := validatePattern(pattern)
} if err != nil {
log.Fatal(err)
if currentPattern != "" { }
paternArray = append(paternArray, currentPattern)
} }
watcherConfig := WatcherConfig{ watcherConfig := WatcherConfig{
excludedDirs: globList(config.ExcludedDirs), excludedGlobs: globList(zqdgr.Config.ExcludedGlobs),
pattern: paternArray, pattern: patternArray,
} }
watcher, err := NewWatcher(&watcherConfig) watcher, err := NewWatcher(&watcherConfig)
@@ -577,13 +625,17 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// We use this timer to deduplicate events. err = watcher.AddFile(path.Join(zqdgr.WorkingDirectory, "zqdgr.config.json"))
if err != nil {
log.Fatal(err)
}
// tailing edge debounce of file system events
var ( var (
// Wait 100ms for new events; each new event resets the timer.
waitFor = 100 * time.Millisecond waitFor = 100 * time.Millisecond
// Keep track of the timers, as path → timer. mu sync.Mutex
mu sync.Mutex // watched filepath -> timer
timers = make(map[string]*time.Timer) timers = make(map[string]*time.Timer)
) )
go func() { go func() {
@@ -600,10 +652,56 @@ func main() {
if !ok { if !ok {
timer = time.AfterFunc(waitFor, func() { timer = time.AfterFunc(waitFor, func() {
slog.Debug("FSnotify event received", "event", event)
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
fmt.Println("File changed:", event.Name) slog.Debug("File changed", "file", event.Name)
if directoryShouldBeTracked(&watcherConfig, event.Name) {
watcher.(NotifyWatcher).watcher.Add(event.Name) fullEventPath, err := filepath.Abs(event.Name)
if err != nil {
log.Fatal(err)
}
slog.Debug("expanded event path", "path", fullEventPath)
// check against the fullpath to make sure that the config file that was changed is
// actually the one we are using
if fullEventPath == filepath.Join(zqdgr.WorkingDirectory, "zqdgr.config.json") {
// re-exec the exact same command
if !*disableReloadConfig {
fmt.Println("zqdgr.config.json has changed, restarting...")
executable, err := os.Executable()
if err != nil {
log.Fatal(err)
}
script.mutex.Lock()
err = script.Stop()
if err != nil {
log.Fatal(err)
}
script.mutex.Unlock()
err = syscall.Exec(executable, originalArgs, os.Environ())
if err != nil {
log.Fatal(err)
}
panic("unreachable")
}
}
if pathShouldBeTracked(&watcherConfig, event.Name) {
if event.Op&fsnotify.Create == fsnotify.Create {
slog.Debug("Adding new file to watcher", "file", event.Name)
watcher.(NotifyWatcher).watcher.Add(event.Name)
}
if event.Op&fsnotify.Remove == fsnotify.Remove {
slog.Debug("Removing file from watcher", "file", event.Name)
watcher.(NotifyWatcher).watcher.Remove(event.Name)
}
} }
if pathMatches(&watcherConfig, event.Name) { if pathMatches(&watcherConfig, event.Name) {
@@ -636,8 +734,17 @@ func main() {
} }
} }
}() }()
// block until the script exits with a zero (aka, it normally exited on its own)
for {
exitCode := <-script.exitCode
slog.Debug("Script exited", "script", scriptName, "exitCode", exitCode)
if exitCode == 0 {
os.Exit(0)
}
}
} }
script.Wait() // block until the script exits
os.Exit(script.exitCode) os.Exit(<-script.exitCode)
} }

View File

@@ -4,7 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"log/slog"
"path/filepath" "path/filepath"
"strings"
"github.com/bmatcuk/doublestar" "github.com/bmatcuk/doublestar"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -14,27 +16,45 @@ type globList []string
func (g *globList) Matches(value string) bool { func (g *globList) Matches(value string) bool {
for _, v := range *g { for _, v := range *g {
// if the pattern matches a filepath pattern
if match, err := filepath.Match(v, value); err != nil { if match, err := filepath.Match(v, value); err != nil {
log.Fatalf("Bad pattern \"%s\": %s", v, err.Error()) log.Fatalf("Bad pattern \"%s\": %s", v, err.Error())
} else if match { } else if match {
return true return true
} }
// or if the path starts with the pattern
if strings.HasSuffix(value, v) {
return true
}
} }
return false return false
} }
func matchesPattern(pattern []string, path string) bool { func matchesPattern(pattern []string, path string) bool {
for _, p := range pattern { for _, p := range pattern {
slog.Debug("checking path against pattern", "pattern", p, "path", path)
if matched, _ := doublestar.Match(p, path); matched { if matched, _ := doublestar.Match(p, path); matched {
slog.Debug("path matches pattern", "pattern", p, "path", path)
return true return true
} }
} }
return false return false
} }
func directoryShouldBeTracked(cfg *WatcherConfig, path string) bool { func pathShouldBeTracked(cfg *WatcherConfig, path string) bool {
base := filepath.Dir(path) base := filepath.Dir(path)
return matchesPattern(cfg.pattern, path) && !cfg.excludedDirs.Matches(base)
slog.Debug("checking file against path", "path", path, "base", base)
if cfg.excludedGlobs.Matches(base) {
slog.Debug("file is excluded", "path", path)
return false
}
return matchesPattern(cfg.pattern, path)
} }
func pathMatches(cfg *WatcherConfig, path string) bool { func pathMatches(cfg *WatcherConfig, path string) bool {
@@ -42,13 +62,14 @@ func pathMatches(cfg *WatcherConfig, path string) bool {
} }
type WatcherConfig struct { type WatcherConfig struct {
excludedDirs globList excludedGlobs globList
pattern globList pattern globList
} }
type FileWatcher interface { type FileWatcher interface {
Close() error Close() error
AddFiles() error AddFiles() error
AddFile(path string) error
add(path string) error add(path string) error
getConfig() *WatcherConfig getConfig() *WatcherConfig
} }
@@ -62,6 +83,12 @@ func (n NotifyWatcher) Close() error {
return n.watcher.Close() return n.watcher.Close()
} }
func (n NotifyWatcher) AddFile(path string) error {
slog.Debug("manually adding file", "file", path)
return n.add(path)
}
func (n NotifyWatcher) AddFiles() error { func (n NotifyWatcher) AddFiles() error {
return addFiles(n) return addFiles(n)
} }
@@ -79,10 +106,12 @@ func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) {
err := errors.New("no config specified") err := errors.New("no config specified")
return nil, err return nil, err
} }
w, err := fsnotify.NewWatcher() w, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return NotifyWatcher{ return NotifyWatcher{
watcher: w, watcher: w,
cfg: cfg, cfg: cfg,
@@ -92,18 +121,43 @@ func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) {
func addFiles(fw FileWatcher) error { func addFiles(fw FileWatcher) error {
cfg := fw.getConfig() cfg := fw.getConfig()
for _, pattern := range cfg.pattern { for _, pattern := range cfg.pattern {
slog.Debug("adding pattern", "pattern", pattern)
matches, err := doublestar.Glob(pattern) matches, err := doublestar.Glob(pattern)
if err != nil { if err != nil {
log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error()) log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error())
} }
trackedDirs := make(map[string]bool)
for _, match := range matches { for _, match := range matches {
if directoryShouldBeTracked(cfg, match) { base := filepath.Dir(match)
// this allows us to track file creations and deletions
if !trackedDirs[base] {
if cfg.excludedGlobs.Matches(base) {
slog.Debug("directory is excluded", "file", match)
continue
}
trackedDirs[base] = true
slog.Debug("adding directory", "dir", base)
if err := fw.add(base); err != nil {
return fmt.Errorf("FileWatcher.Add(): %v", err)
}
}
slog.Debug("adding file", "file", match)
if pathShouldBeTracked(cfg, match) {
slog.Debug("path should be tracked", "file", match)
if err := fw.add(match); err != nil { if err := fw.add(match); err != nil {
return fmt.Errorf("FileWatcher.Add(): %v", err) return fmt.Errorf("FileWatcher.Add(): %v", err)
} }
} }
} }
} }
return nil return nil
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "zqdgr", "name": "zqdgr",
"version": "0.0.3", "version": "0.0.6",
"description": "zqdgr is a quick and dirty Golang runner", "description": "zqdgr is a quick and dirty Golang runner",
"author": "juls0730", "author": "juls0730",
"license": "BSL-1.0", "license": "BSL-1.0",
@@ -14,9 +14,12 @@
"dev": "sleep 5; echo 'test' && sleep 2 && echo 'test2'", "dev": "sleep 5; echo 'test' && sleep 2 && echo 'test2'",
"test": "zqdgr test:1 && zqdgr test:2 && zqdgr test:3 && zqdgr test:4", "test": "zqdgr test:1 && zqdgr test:2 && zqdgr test:3 && zqdgr test:4",
"test:1": "echo 'a'", "test:1": "echo 'a'",
"test:2": "false", "test:2": "true",
"test:3": "echo 'b'", "test:3": "echo 'b'",
"test:4": "zqdgr test:3", "test:4": "zqdgr test:3",
"test:5": "zqdgr test:6",
"test:6": "zqdgr test:7",
"test:7": "zqdgr test:5",
"recursive": "zqdgr recursive" "recursive": "zqdgr recursive"
}, },
"pattern": "**/*.go" "pattern": "**/*.go"