refactor(tools): bin/{subject}/{method} layout; Go serve+watch modules
Move serve/ (module) -> bin/server, tools/ -> bin/tools, replace bin/kb-watch bash with bin/watch Go package; self-executing Go shebangs bin/serve.go and bin/kb/watch.go; Docker + CI + git/import + docs repointed. Multi-stage image builds static serve+watch binaries (no Go runtime in container).
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// Package watch polls corpus directories for changes and re-runs bin/kb/index.
|
||||
//
|
||||
// Port of the former bin/kb-watch bash script to an importable, testable Go
|
||||
// package. Polls file mtimes (no inotify deps); cheap and reliable.
|
||||
package watch
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options controls the polling loop. Zero value uses defaults.
|
||||
type Options struct {
|
||||
Dirs []string
|
||||
Interval time.Duration
|
||||
// IndexCmd is the kb/index command template. %s is replaced by the repo
|
||||
// root (from KB_ROOT). Defaults to `python3 <root>/bin/kb/index`.
|
||||
IndexCmd string
|
||||
}
|
||||
|
||||
// Run blocks forever polling Dirs (defaults: KB_WATCH_DIRS or /corpus) every
|
||||
// Interval (default 30s) and re-indexing when files change. KB_ROOT names the
|
||||
// repo root used to locate bin/kb/index.
|
||||
func Run(args []string) {
|
||||
opts := fromEnv(args)
|
||||
root, _ := os.Getwd()
|
||||
if r := os.Getenv("KB_ROOT"); r != "" {
|
||||
root = r
|
||||
}
|
||||
log.Printf("watch: dirs=%v interval=%s root=%s", opts.Dirs, opts.Interval, root)
|
||||
var last string
|
||||
for {
|
||||
if flag := Stamp(opts.Dirs); flag != "" && flag != last {
|
||||
last = flag
|
||||
reindex(opts.IndexCmd, root)
|
||||
}
|
||||
time.Sleep(opts.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
func fromEnv(args []string) Options {
|
||||
opts := Options{Interval: 30 * time.Second}
|
||||
if raw := os.Getenv("KB_WATCH_INTERVAL"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
opts.Interval = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
defDirs := "/corpus"
|
||||
if raw := os.Getenv("KB_WATCH_DIRS"); raw != "" {
|
||||
defDirs = raw
|
||||
}
|
||||
if len(args) > 0 {
|
||||
opts.Dirs = args
|
||||
} else {
|
||||
for _, d := range strings.Split(defDirs, " ") {
|
||||
if d != "" {
|
||||
opts.Dirs = append(opts.Dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
pys := os.Getenv("KB_PY")
|
||||
if pys == "" {
|
||||
pys = "python3"
|
||||
}
|
||||
opts.IndexCmd = pys + " <root>/bin/kb/index"
|
||||
return opts
|
||||
}
|
||||
|
||||
// Stamp returns a rolling fingerprint (newest mtime under dirs) that changes
|
||||
// whenever any corpus file is touched. Empty when no files found.
|
||||
func Stamp(dirs []string) string {
|
||||
var newest time.Time
|
||||
for _, dir := range dirs {
|
||||
_ = filepath.WalkDir(dir, func(path string, _ os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info, e := os.Stat(path); e == nil && info.ModTime().After(newest) {
|
||||
newest = info.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if newest.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(newest.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func reindex(template, root string) {
|
||||
cmd := strings.ReplaceAll(template, "<root>", root)
|
||||
parts := strings.Fields(cmd)
|
||||
c := exec.Command(parts[0], parts[1:]...)
|
||||
out, err := c.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("watch: index failed: %v\n%s", err, out)
|
||||
} else {
|
||||
log.Printf("watch: re-indexed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStampChangesWhenFileTouched(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a.md")
|
||||
if err := os.WriteFile(a, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1 := Stamp([]string{dir})
|
||||
if s1 == "" {
|
||||
t.Fatal("stamp empty for a dir with a file")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if err := os.WriteFile(a, []byte("y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2 := Stamp([]string{dir}); s2 == s1 {
|
||||
t.Fatal("stamp did not change after the file was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampEmptyForMissingDir(t *testing.T) {
|
||||
if s := Stamp([]string{filepath.Join(t.TempDir(), "nope")}); s != "" {
|
||||
t.Fatalf("stamp = %q, want empty for missing dir", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEnvDefaults(t *testing.T) {
|
||||
t.Setenv("KB_WATCH_INTERVAL", "")
|
||||
t.Setenv("KB_WATCH_DIRS", "")
|
||||
t.Setenv("KB_PY", "")
|
||||
opts := fromEnv(nil)
|
||||
if len(opts.Dirs) == 0 || opts.Dirs[0] != "/corpus" {
|
||||
t.Fatalf("default dirs = %v, want [/corpus]", opts.Dirs)
|
||||
}
|
||||
if opts.Interval != 30*time.Second {
|
||||
t.Fatalf("default interval = %s, want 30s", opts.Interval)
|
||||
}
|
||||
if opts.IndexCmd == "" {
|
||||
t.Fatal("default index cmd is empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user