Refactor watcher to use cross-platform fsnotify, remove deprecated platform-specific files, and add comprehensive README.md

This commit is contained in:
2026-06-25 14:59:08 +02:00
parent c92ef2e0a9
commit d53fe33e03
6 changed files with 293 additions and 235 deletions
+89
View File
@@ -0,0 +1,89 @@
# gbld — Simple Go Builder & Live-Reloader
`gbld` is a hot-reloader and build-automation utility for Go projects. It automatically tracks build numbers, formats source files using `goimports`, compiles the binary, manages the running process, and watches the project directory for changes to trigger automatic rebuilding and restarting.
---
## Key Features
- 🔄 **Hot Reloader**: Automatically watches for file changes (via `fsnotify`) and rebuilds/restarts your application instantly.
- 🔢 **Build Counter**: Increments a build number in `build.go` automatically with every compiled build.
- 🧹 **Auto-Import & Formatting**: Runs `goimports` on save (optional via `-i`) to keep your imports and formatting clean.
- 🖥️ **Interactive Menu**: Uses a select menu (`gbld.menu.json`) if run without arguments, letting you define and run custom build tasks.
- 🚀 **Cross-Compilation**: Easily builds binaries for multiple target operating systems (macOS, Linux, Windows) and architectures (amd64, arm64) using a single command.
- ☁️ **Remote Uploads**: Integrated command to securely upload build artifacts to a deployment server (`gozilla`).
---
## Installation
### Prerequisites
Make sure you have [Go](https://go.dev/) and `goimports` installed and available in your system path:
```bash
go install golang.org/x/tools/cmd/goimports@latest
```
### Building from Source
Clone the repository and compile `gbld`:
```bash
git clone https://git.fhi.mpg.de/mike/gbld.git
cd gbld
go build -o /usr/local/bin/gbld
```
---
## Usage
```bash
gbld [flags] [target_name]
```
> [!NOTE]
> If `target_name` is omitted, the name of the current directory is used. The tool expects a main source file matching that name (e.g., `target_name.go`).
### CLI Flags
| Flag | Argument | Description |
| :--- | :--- | :--- |
| `-b` | None | **Build-only** mode (compiles the binary but does not run it). |
| `-1` | None | Run **once** (does not watch the directory for changes). |
| `-i` | None | Runs `goimports` to clean up imports before building. |
| `-o` | `<args>` | Space-separated arguments to pass to the compiled binary on execution. |
| `-c` | `<command>` | Command to execute immediately after a successful build (before execution). |
| `-a` | None | Builds binaries for all supported platforms (Windows, macOS, Linux for AMD64/ARM64). |
| `-u` | None | Uploads built binaries via SSH/SCP to the `gozilla` server. |
| `-v` | None | Displays the current version of `gbld`. |
| `-h` | None | Displays help and usage information. |
---
## Configuration (`gbld.menu.json`)
To make running common tasks easier, you can place a `gbld.menu.json` file in your project directory. If you run `gbld` without any flags, it will parse this file and present an interactive selection menu:
```json
{
"Build and Run (hot reload)": ["-i"],
"Build Only": ["-b", "-i", "-1"],
"Show Help": ["-h"]
}
```
---
## Under the Hood
### Build Increment
When compiling, `gbld` parses `build.go` to find a pattern matching `var build = "<number>"`. It increments this number by 1 and updates the file automatically. This is useful for stamping build numbers/versions inside your binary.
### Automatic Backups
Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace:
```
./tmp/gbld.go.20260625145800
```
### Process Management
When hot-reloading, `gbld` handles process termination gracefully:
1. It sends an `os.Interrupt` (SIGINT) to the running binary.
2. It waits up to 1 second for the process to exit clean.
3. If it does not exit within the timeout, it terminates the process forcefully (`SIGKILL`).
+1 -1
View File
@@ -1,3 +1,3 @@
package main package main
var build = "354" var build = "362"
+113 -116
View File
@@ -16,9 +16,10 @@ import (
"time" "time"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/fsnotify/fsnotify"
) )
var version = "1.19.6" var version = "1.20.1"
var UPDATEURL = "http://gozilla.fhi.mpg.de/gbld" var UPDATEURL = "http://gozilla.fhi.mpg.de/gbld"
@@ -27,9 +28,14 @@ var arches = []string{"arm64", "amd64"}
var GO string var GO string
var GOIMPORTS string var GOIMPORTS string
var xmd *exec.Cmd
var done chan bool var done chan bool
var r1, r2, r3 *regexp.Regexp var r1 = regexp.MustCompile(`^\.`)
var r2 = regexp.MustCompile(`build\.go`)
var r3 = regexp.MustCompile(`\.go$`)
var r4 = regexp.MustCompile(`var\s+build\s*=\s*"(.*)"$`)
var r5 = regexp.MustCompile(`(?i)var\s+version\s*=\s*"(.*)"$`)
func main() { // ========================================================================================== main func main() { // ========================================================================================== main
@@ -52,7 +58,7 @@ func main() { // ===============================================================
os.Args = append(os.Args, buildjson[tmp]...) os.Args = append(os.Args, buildjson[tmp]...)
} else { } else {
PE(err.Error()) PE(err.Error())
os.Exit(0); os.Exit(1)
} }
} else { } else {
os.Args = []string{"gbld","-b","-i","-1",dirName()} os.Args = []string{"gbld","-b","-i","-1",dirName()}
@@ -105,21 +111,18 @@ func main() { // ===============================================================
path, err := exec.LookPath("go") // ··························································· check for 'go' path, err := exec.LookPath("go") // ··························································· check for 'go'
if err != nil { if err != nil {
fmt.Println(Crb("Error: ") + Cwb("go") + " not found") fmt.Println(Crb("Error: ") + Cwb("go") + " not found")
os.Exit(0) os.Exit(1)
} }
GO = path GO = path
path, err = exec.LookPath("goimports") // ·············································· check for 'goimports' path, err = exec.LookPath("goimports") // ·············································· check for 'goimports'
if err != nil { if err != nil {
fmt.Println(Crb("Error: ") + Cwb("goimports") + " not found") fmt.Println(Crb("Error: ") + Cwb("goimports") + " not found")
os.Exit(0) os.Exit(1)
} }
GOIMPORTS = path GOIMPORTS = path
if *opt_v { // ·············································································· show version
if *opt_v { // ·················································································· show version
info() info()
os.Exit(0) os.Exit(0)
} }
@@ -134,31 +137,26 @@ func main() { // ===============================================================
name=dirName() name=dirName()
if len(name) == 0 { if len(name) == 0 {
flag.Usage() flag.Usage()
os.Exit(0) os.Exit(1)
} }
} }
file := name + ".go" file := name + ".go"
if !fileExists(file) { if !fileExists(file) {
P(Crb("file not found: " + file)) P(Crb("file not found: " + file))
os.Exit(0) os.Exit(1)
} }
var ot int64 = 0
var st int = 1
var xmd *exec.Cmd = nil
if (!*opt_1 && !*opt_a && !*opt_u) { if (!*opt_1 && !*opt_a && !*opt_u) {
PN("\033[2J\033[1;1H") PN("\033[2J\033[1;1H")
} }
info() info()
if *opt_a { // ······························································· build for other platforms if *opt_a { // ······························································· build for other platforms
targetversion:=gettargetversion(file) targetversion:=gettargetversion()
P(Ccb("build for all platforms")) P(Ccb("build for all platforms"))
for _, osys := range oses { for _, osys := range oses {
for _, arch := range arches { for _, arch := range arches {
ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch
P(Cy(ofile)) P(Cy(ofile))
@@ -167,13 +165,11 @@ func main() { // ===============================================================
cmd.Run() cmd.Run()
} }
} }
os.WriteFile("bin/version.txt", []byte(targetversion), 0644) os.WriteFile("bin/version.txt", []byte(targetversion), 0644)
} }
if *opt_u { // ······································································ upload builds to gozilla if *opt_u { // ······································································ upload builds to gozilla
targetversion:=gettargetversion(file) targetversion:=gettargetversion()
P(Ccb("upload builds to gozilla")) P(Ccb("upload builds to gozilla"))
for _, osys := range oses { for _, osys := range oses {
@@ -181,12 +177,10 @@ func main() { // ===============================================================
ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch
_, err := os.Stat(ofile) _, err := os.Stat(ofile)
if err == nil { if err == nil {
cmd := exec.Command("ssh","root@gozilla","rm", "/db/www/"+name+"/*_"+osys+"_"+arch ) cmd := exec.Command("ssh","root@gozilla","rm", "/db/www/"+name+"/*_"+osys+"_"+arch )
P(Cy(cmd)) P(Cy(cmd))
cmd.Run() cmd.Run()
cmd = exec.Command("scp",ofile, "root@gozilla:/db/www/"+name) cmd = exec.Command("scp",ofile, "root@gozilla:/db/www/"+name)
P(Cy(cmd)) P(Cy(cmd))
cmd.Run() cmd.Run()
@@ -199,48 +193,85 @@ func main() { // ===============================================================
P(Cy(cmd)) P(Cy(cmd))
cmd.Run() cmd.Run()
} }
} }
if (*opt_a || *opt_u) { os.Exit(0) } if (*opt_a || *opt_u) { os.Exit(0) }
r1 = regexp.MustCompile("^\\.") // Initial build
r2 = regexp.MustCompile("build\\.go") buildAndRun(name, file, *opt_b, *opt_1, *opt_i, *opt_c, *opt_o, *opt_x)
r3 = regexp.MustCompile("\\.go$")
for { // ------------------------------------------------------------------------------------------- main loop if *opt_1 {
lmf := lmf("./") os.Exit(0)
var dt int64 = 0
if fileExists(name) {
dt = lmf - filemodtime(name)
} else {
dt = 1
} }
if (dt > 0 && ot != lmf) || st == 1 { // ·············································· new version detected // Set up fsnotify watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
PE(err.Error())
os.Exit(1)
}
defer watcher.Close()
if st == 0 && !*opt_1{ err = watcher.Add(".")
if err != nil {
PE(err.Error())
os.Exit(1)
}
events := make(chan bool)
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
fileName := filepath.Base(event.Name)
if r3.MatchString(fileName) && !r1.MatchString(fileName) && !r2.MatchString(fileName) {
events <- true
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
PE(err.Error())
}
}
}()
var timer *time.Timer
for {
select {
case <-events:
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(100*time.Millisecond, func() {
buildAndRun(name, file, *opt_b, *opt_1, *opt_i, *opt_c, *opt_o, *opt_x)
})
}
}
}
func buildAndRun(name, file string, opt_b, opt_1, opt_i bool, opt_c, opt_o string, opt_x bool) {
PN("\033[2J\033[1;1H") PN("\033[2J\033[1;1H")
}
P(Cc("--- start compiling " + file + " ---")) P(Cc("--- start compiling " + file + " ---"))
stopRunning(xmd) stopRunning(xmd)
o := "" // ········································································· increase build number o := "" // ········································································· increase build number
func() {
readFile, err := os.Open("build.go") readFile, err := os.Open("build.go")
if err == nil { if err == nil {
defer readFile.Close() defer readFile.Close()
backup("build.go") backup("build.go")
fileScanner := bufio.NewScanner(readFile) fileScanner := bufio.NewScanner(readFile)
fileScanner.Split(bufio.ScanLines) fileScanner.Split(bufio.ScanLines)
r := regexp.MustCompile("var\\s+build\\s*=\\s*\"(.*)\"$")
for fileScanner.Scan() { for fileScanner.Scan() {
line := fileScanner.Text() line := fileScanner.Text()
s := r.FindStringSubmatch(line) s := r4.FindStringSubmatch(line)
if len(s) > 0 { if len(s) > 0 {
b := Atoi(s[1]) + 1 b := Atoi(s[1]) + 1
o = o + "var build = \"" + Itoa(b) + "\"\n" o = o + "var build = \"" + Itoa(b) + "\"\n"
@@ -251,29 +282,11 @@ func main() { // ===============================================================
} }
os.WriteFile("build.go", []byte(o), 0644) os.WriteFile("build.go", []byte(o), 0644)
} }
}()
targetversion:="" _ = gettargetversion()
readFile, err = os.Open(file)
if err == nil {
fileScanner := bufio.NewScanner(readFile)
fileScanner.Split(bufio.ScanLines)
r := regexp.MustCompile("var\\s+version\\s*=\\s*\"(.*)\"$")
for fileScanner.Scan() {
line := fileScanner.Text()
s := r.FindStringSubmatch(line)
if len(s) > 0 {
targetversion=s[1]
}
}
readFile.Close()
}
if (targetversion=="") { if opt_i { // ············································································· run goimports
PE("target version not found")
os.Exit(0)
}
if *opt_i { // ············································································· run goimports
re := regexp.MustCompile(`(?s)import\s*\((.*?)\)`) re := regexp.MustCompile(`(?s)import\s*\((.*?)\)`)
files, err := os.ReadDir(".") files, err := os.ReadDir(".")
if err != nil { fmt.Printf("Error: %v\n", err); return } if err != nil { fmt.Printf("Error: %v\n", err); return }
@@ -296,30 +309,25 @@ func main() { // ===============================================================
} }
} }
} }
} }
cmd := exec.Command("go", "build", "-o", "bin/"+name, name) cmd := exec.Command("go", "build", "-o", "bin/"+name, name)
PF("%s %s\n", Cw("running:"), Cyb("go build "+name)) PF("%s %s\n", Cw("running:"), Cyb("go build "+name))
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { // ·········································································· build failed if err != nil { // ·········································································· build failed
PN(Cy(string(out))) PN(Cy(string(out)))
P(Cr("--- build failed: " + name + " ---")) P(Cr("--- build failed: " + name + " ---"))
} else { // ·············································································· build succeeded } else { // ·············································································· build succeeded
P(Cb("--- compiling done: " + name + " ---")) P(Cb("--- compiling done: " + name + " ---"))
if len(*opt_c) > 0 { // ··························································· execute post command if len(opt_c) > 0 { // ··························································· execute post command
params := strings.Fields(*opt_c) params := strings.Fields(opt_c)
app := params[0] app := params[0]
args := params[1:] args := params[1:]
P(Cw(*opt_c)) P(Cw(opt_c))
xmd = exec.Command(app, args...) xmd = exec.Command(app, args...)
xmd.Stdout = os.Stdout xmd.Stdout = os.Stdout
xmd.Stdin = os.Stdin xmd.Stdin = os.Stdin
@@ -327,12 +335,12 @@ func main() { // ===============================================================
xmd.Run() xmd.Run()
} }
if !*opt_b && !*opt_a { if !opt_b {
stopRunning(xmd) stopRunning(xmd)
args := []string{} args := []string{}
if len(*opt_o) > 0 { if len(opt_o) > 0 {
args = strings.Fields(*opt_o) args = strings.Fields(opt_o)
} }
xmd = exec.Command("./bin/"+name, args...) // ······································· start new build xmd = exec.Command("./bin/"+name, args...) // ······································· start new build
@@ -345,23 +353,12 @@ func main() { // ===============================================================
} }
P(Cg("--- end building: " + name + " ---")) P(Cg("--- end building: " + name + " ---"))
if *opt_x { if opt_x {
os.Exit(0) os.Exit(0)
} }
} }
ot = lmf
}
if *opt_1 {
os.Exit(0)
}
st = 0
time.Sleep(500 * time.Millisecond)
}
} }
func backup(src string) { // ---------------------------------------------------------------------- backup files func backup(src string) { // ---------------------------------------------------------------------- backup files
sourceFile,_ := os.Open(src) sourceFile,_ := os.Open(src)
defer sourceFile.Close() defer sourceFile.Close()
@@ -373,61 +370,61 @@ func backup(src string) { // ---------------------------------------------------
destFile.Close() destFile.Close()
} }
// ===================================================================================================== SUPPORT func gettargetversion() string { // ------------------------------------------- detect target version
files, err := filepath.Glob("*.go")
if err != nil {
PE(SF("Error listing files: %v", err))
os.Exit(1)
}
func gettargetversion(file string) string { // ------------------------------------------- detect target version targetversion := ""
targetversion:="" for _, file := range files {
readFile, err := os.Open(file) readFile, err := os.Open(file)
if err == nil { if err != nil {
continue
}
fileScanner := bufio.NewScanner(readFile) fileScanner := bufio.NewScanner(readFile)
fileScanner.Split(bufio.ScanLines) fileScanner.Split(bufio.ScanLines)
r := regexp.MustCompile("var\\s+version\\s*=\\s*\"(.*)\"$")
for fileScanner.Scan() { for fileScanner.Scan() {
line := fileScanner.Text() line := fileScanner.Text()
s := r.FindStringSubmatch(line) s := r5.FindStringSubmatch(line)
if len(s) > 0 { if len(s) > 0 {
targetversion=s[1] targetversion = s[1]
readFile.Close()
return targetversion
} }
} }
readFile.Close() readFile.Close()
} }
if (targetversion=="") { if targetversion == "" {
PE("target version not found") PE("target version not found")
os.Exit(0) os.Exit(1)
} }
return targetversion return targetversion
} }
func lmf(dir string) int64 { // -------------------------------------- get last modification date from .go files
var tmax int64 = 0
files, err := os.ReadDir(dir)
if err == nil {
for _, file := range files {
if r3.MatchString(file.Name()) {
if !r1.MatchString(file.Name()) && !r2.MatchString(file.Name()) {
tf := filemodtime(file.Name())
if tf > tmax {
tmax = tf
}
}
}
}
}
return tmax
}
func stopRunning(cmd *exec.Cmd) { // -------------------------------------------------- stop running old process func stopRunning(cmd *exec.Cmd) { // -------------------------------------------------- stop running old process
if cmd == nil || cmd.ProcessState != nil && cmd.ProcessState.Exited() || cmd.Process == nil { if cmd == nil || cmd.ProcessState != nil && cmd.ProcessState.Exited() || cmd.Process == nil {
return return
} }
err := cmd.Process.Kill() err := cmd.Process.Signal(os.Interrupt)
if err == nil { if err == nil {
cmd.Wait() done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-time.After(1 * time.Second):
cmd.Process.Kill()
<-done
case <-done:
}
P("process stopped:", cmd.Process.Pid) P("process stopped:", cmd.Process.Pid)
} else {
cmd.Process.Kill()
cmd.Wait()
P("process stopped forcefully:", cmd.Process.Pid)
} }
} }
+2 -2
View File
@@ -4,8 +4,10 @@ go 1.26.1
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/Masterminds/semver/v3 v3.5.0
github.com/eknkc/basex v1.0.1 github.com/eknkc/basex v1.0.1
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.9.0
github.com/minio/selfupdate v0.6.0 github.com/minio/selfupdate v0.6.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.19.0
@@ -13,8 +15,6 @@ require (
require ( require (
aead.dev/minisign v0.2.0 // indirect aead.dev/minisign v0.2.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
-14
View File
@@ -1,14 +0,0 @@
//go:build linux
package main
import (
"os"
"syscall"
)
func filemodtime(build string) int64 {
fibuild, _ := os.Stat(build)
tb := fibuild.Sys().(*syscall.Stat_t).Mtim
return tb.Sec
}
-14
View File
@@ -1,14 +0,0 @@
//go:build darwin
package main
import (
"os"
"syscall"
)
func filemodtime(build string) int64 {
fibuild, _ := os.Stat(build)
tb := fibuild.Sys().(*syscall.Stat_t).Mtimespec
return tb.Sec
}