Developer Experience
figs := figtree.With(figtree.Options{
ConfigFile: "config.yaml",
Watch: true,
Tracking: true,
})
if err := figs.Load(); err != nil {
log.Fatal(err)
}
// Everything below already existed — no new API needed
for mutation := range figs.Mutations() {
log.Printf("%s changed: %v → %v", mutation.Property, mutation.Old, mutation.New)
}
Watcher watcher.go
// watcher.go
package figtree
import (
"fmt"
"log"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// ============================================================================
// OPTIONS ADDITIONS
// ============================================================================
// These fields are added to the existing Options struct.
// Shown here isolated for clarity — merge into your existing Options{}.
//
// type Options struct {
// // ... existing fields ...
// Watch bool // enable fsnotify file watching
// WatchDebounce time.Duration // how long to wait before reloading after a change (default 100ms)
// OnWatchError func(err error) // optional hook for watch errors; defaults to log.Println
// }
const defaultWatchDebounce = 100 * time.Millisecond
// ============================================================================
// WATCHER STATE — lives inside the tree struct
// ============================================================================
// watcherState is embedded in the tree struct.
// Add `watcher watcherState` to your existing tree struct definition.
type watcherState struct {
mu sync.Mutex
fsw *fsnotify.Watcher
watchedPath string // absolute path of the file being watched
active bool
stopCh chan struct{}
}
// ============================================================================
// INTERNAL: called at the end of any successful Load/Parse that resolved a file
// ============================================================================
// activateWatch is called internally after any successful file-based load.
// path is the resolved absolute path of the config file that was loaded.
// It is a no-op if Options.Watch is false or if already watching.
func (t *tree) activateWatch(path string) {
if !t.options.Watch {
return
}
absPath, err := filepath.Abs(path)
if err != nil {
t.handleWatchError(fmt.Errorf("figtree watcher: could not resolve absolute path for %q: %w", path, err))
return
}
t.watcher.mu.Lock()
defer t.watcher.mu.Unlock()
// Already watching this exact file — nothing to do
if t.watcher.active && t.watcher.watchedPath == absPath {
return
}
// Clean up any prior watcher
t.teardownWatchLocked()
fsw, err := fsnotify.NewWatcher()
if err != nil {
t.handleWatchError(fmt.Errorf("figtree watcher: failed to create fsnotify watcher: %w", err))
return
}
if err := fsw.Add(absPath); err != nil {
_ = fsw.Close()
t.handleWatchError(fmt.Errorf("figtree watcher: failed to watch %q: %w", absPath, err))
return
}
stopCh := make(chan struct{})
t.watcher.fsw = fsw
t.watcher.watchedPath = absPath
t.watcher.active = true
t.watcher.stopCh = stopCh
go t.watchLoop(fsw, absPath, stopCh)
}
// ============================================================================
// WATCH LOOP — goroutine that processes fsnotify events
// ============================================================================
func (t *tree) watchLoop(fsw *fsnotify.Watcher, path string, stopCh <-chan struct{}) {
debounce := t.options.WatchDebounce
if debounce <= 0 {
debounce = defaultWatchDebounce
}
var (
debounceTimer *time.Timer
timerMu sync.Mutex
)
scheduleReload := func() {
timerMu.Lock()
defer timerMu.Unlock()
if debounceTimer != nil {
debounceTimer.Reset(debounce)
return
}
debounceTimer = time.AfterFunc(debounce, func() {
timerMu.Lock()
debounceTimer = nil
timerMu.Unlock()
t.reloadFromWatch(path)
})
}
for {
select {
case <-stopCh:
timerMu.Lock()
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
timerMu.Unlock()
return
case event, ok := <-fsw.Events:
if !ok {
return
}
// React to writes and renames (rename covers atomic-write editors)
if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) {
scheduleReload()
}
// If the file was removed entirely, surface an error but keep watching
if event.Has(fsnotify.Remove) {
t.handleWatchError(fmt.Errorf("figtree watcher: watched file %q was removed", path))
}
case err, ok := <-fsw.Errors:
if !ok {
return
}
t.handleWatchError(fmt.Errorf("figtree watcher: fsnotify error on %q: %w", path, err))
}
}
}
// ============================================================================
// RELOAD — re-parses the file and feeds diffs into the mutation pipeline
// ============================================================================
// reloadFromWatch re-parses the watched config file.
// It compares new values against current values and calls Store() for any
// that changed — which naturally triggers validators, callbacks, rules,
// and the Mutations() channel exactly as a programmatic Store() would.
func (t *tree) reloadFromWatch(path string) {
// Load the raw key-value map from the file without touching the live tree
incoming, err := t.parseFileToMap(path)
if err != nil {
t.handleWatchError(fmt.Errorf("figtree watcher: reload parse error on %q: %w", path, err))
return
}
for key, newRawValue := range incoming {
fig := t.Fig(key)
if fig == nil {
// Key in file not registered in tree — skip silently
// (same behaviour as initial Load)
continue
}
// Convert and store — Store() handles rules, validators, callbacks,
// and emits to Mutations() if tracking is enabled.
// A RulePreventChange or RulePanicOnChange will reject it here
// exactly as it would reject a manual Store() call.
storeErr := t.storeFromRaw(fig.Mutagenesis, key, newRawValue)
if storeErr != nil {
t.handleWatchError(fmt.Errorf(
"figtree watcher: could not apply reloaded value for key %q: %w",
key, storeErr,
))
}
}
}
// parseFileToMap re-parses the file at path and returns a flat
// map[string]string of raw string values, routing to the correct
// parser based on file extension. This is the same routing table
// used by LoadFile/ParseFile but returns raw values rather than
// immediately storing them.
func (t *tree) parseFileToMap(path string) (map[string]interface{}, error) {
ext := strings.ToLower(filepath.Ext(path))
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("could not open %q: %w", path, err)
}
defer f.Close()
// Route to the parser table you build per the format discussion
parser, ok := t.parserFor(ext)
if !ok {
return nil, fmt.Errorf("no parser registered for extension %q", ext)
}
return parser(f)
}
// ============================================================================
// TEARDOWN
// ============================================================================
// StopWatch gracefully shuts down the file watcher.
// Safe to call multiple times. Exported so the caller can shut down
// cleanly on SIGINT/SIGTERM alongside context cancellation.
func (t *tree) StopWatch() {
t.watcher.mu.Lock()
defer t.watcher.mu.Unlock()
t.teardownWatchLocked()
}
// teardownWatchLocked stops the watch goroutine and closes fsnotify.
// Caller must hold t.watcher.mu.
func (t *tree) teardownWatchLocked() {
if !t.watcher.active {
return
}
close(t.watcher.stopCh)
_ = t.watcher.fsw.Close()
t.watcher.active = false
t.watcher.watchedPath = ""
t.watcher.fsw = nil
t.watcher.stopCh = nil
}
// ============================================================================
// ERROR HANDLER
// ============================================================================
// handleWatchError routes watch errors to the user-supplied OnWatchError
// hook if set, otherwise falls back to log.Println.
func (t *tree) handleWatchError(err error) {
if t.options.OnWatchError != nil {
t.options.OnWatchError(err)
return
}
log.Println(err)
}
Modify loading.go
// In loading.go — end of Load()
func (t *tree) Load() error {
// ... existing logic that resolves t.resolvedConfigPath ...
if err := t.loadFromFile(t.resolvedConfigPath); err != nil {
return err
}
t.activateWatch(t.resolvedConfigPath) // ADD THIS
return nil
}
// In loading.go — end of LoadFile()
func (t *tree) LoadFile(path string) error {
if err := t.loadFromFile(path); err != nil {
return err
}
t.activateWatch(path) // ADD THIS
return nil
}
// In parsing.go — end of ParseFile()
func (t *tree) ParseFile(path string) error {
if err := t.parseFromFile(path); err != nil {
return err
}
t.activateWatch(path) // ADD THIS
return nil
}
// Parse() with no file is a no-op for the watcher — flags and env
// have no filesystem path to watch.
Modify Fruit Interface
// Add to the Fruit interface in types.go
type Fruit interface {
// ... existing methods ...
StopWatch()
}
Dependency
Update go.mod with:
require (
github.com/fsnotify/fsnotify v1.7.0
)
Update vars.go
// vars.go — additions only, merge into existing file
const (
// ... existing consts unchanged ...
defaultWatchDebounce = 100 * time.Millisecond
)
// WatchDebounce is the default delay between a filesystem event and a reload.
// Override before calling Load/LoadFile/ParseFile if your editor needs longer.
var WatchDebounce = defaultWatchDebounce
Update types.go
type Loadable interface {
Load() error
LoadFile(path string) error
Reload() error
StopWatch() // ADDED — graceful shutdown of the fsnotify watcher
}
// watcherState holds all fsnotify lifecycle state for the figTree.
// It is embedded by value in figTree so zero value is safe — active=false
// means the watcher is dormant and no goroutine is running.
type watcherState struct {
mu sync.Mutex
fsw *fsnotify.Watcher // nil when inactive
watchedPath string // absolute path currently watched
active bool
stopCh chan struct{} // closed to signal watchLoop to exit
}
// Options allow you enable mutation tracking on your figs.Grow
type Options struct {
ConfigFile string
// Tracking creates a buffered channel that allows you to select { case mutation, ok := <-figs.Mutations(): }
Tracking bool
// Germinate enables the option to filter os.Args that begin with -test. prefix
Germinate bool
// Harvest allows you to set the buffer size of the Mutations channel
Harvest int
// Pollinate will enable Getters to lookup the environment for changes on every read
Pollinate bool
// IgnoreEnvironment is a part of free will, it lets us disregard our environment (ENV vars)
IgnoreEnvironment bool
// Watch enables automatic fsnotify watching of whatever config file was
// resolved during Load(), LoadFile(), ParseFile(). When a change is
// detected the file is re-parsed and diffed against live values; any
// changed key flows through the normal Store() pipeline — validators,
// callbacks, rules, and the Mutations() channel all fire exactly as they
// would for a programmatic Store() call.
Watch bool
// WatchDebounce is how long to wait after a filesystem event before
// triggering a reload. Defaults to WatchDebounce (100ms). Increase this
// if your editor writes config files in multiple flushes (vim, some IDEs).
WatchDebounce time.Duration
// OnWatchError is an optional hook called whenever the watcher encounters
// an error — parse failures, fsnotify errors, rejected Store() calls on
// reload. If nil, errors are written to log.Println.
OnWatchError func(err error)
}
// figTree stores figs that are defined by their name and figFruit as well as
// a mutations channel and tracking bool for Options.Tracking
type figTree struct {
ConfigFilePath string
GlobalRules []RuleKind
harvest int
pollinate bool
figs map[string]*figFruit
values *sync.Map
withered map[string]witheredFig
aliases map[string]string
sourceLocker sync.RWMutex
mu sync.RWMutex
tracking bool
problems []error
mutationsCh chan Mutation
flagSet *flag.FlagSet
filterTests bool
angel *atomic.Bool
ignoreEnv bool
// watcher holds fsnotify state; zero value is safe (inactive)
watcher watcherState
// watchDebounce and onWatchError are copied out of Options at With() time
watchEnabled bool
watchDebounce time.Duration
onWatchError func(err error)
}
Update figtree.go
func With(opts Options) Plant {
angel := atomic.Bool{}
angel.Store(true)
debounce := opts.WatchDebounce
if debounce <= 0 {
debounce = WatchDebounce
}
fig := &figTree{
ConfigFilePath: opts.ConfigFile,
ignoreEnv: opts.IgnoreEnvironment,
filterTests: opts.Germinate,
pollinate: opts.Pollinate,
tracking: opts.Tracking,
harvest: opts.Harvest,
angel: &angel,
problems: make([]error, 0),
aliases: make(map[string]string),
figs: make(map[string]*figFruit),
values: &sync.Map{},
withered: make(map[string]witheredFig),
mu: sync.RWMutex{},
mutationsCh: make(chan Mutation),
flagSet: flag.NewFlagSet(os.Args[0], flag.ContinueOnError),
// watch options
watchEnabled: opts.Watch,
watchDebounce: debounce,
onWatchError: opts.OnWatchError,
}
fig.flagSet.Usage = fig.Usage
angel.Store(false)
if opts.IgnoreEnvironment {
os.Clearenv()
}
return fig
}
Add watcher.go
package figtree
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// ============================================================================
// PUBLIC: StopWatch
// ============================================================================
// StopWatch gracefully shuts down the file watcher. It is safe to call
// multiple times and safe to call when Watch was never activated.
// Call this alongside your context cancellation on SIGINT/SIGTERM:
//
// case sig := <-sigCh:
// figs.StopWatch()
// cancel()
func (tree *figTree) StopWatch() {
tree.watcher.mu.Lock()
defer tree.watcher.mu.Unlock()
tree.teardownWatchLocked()
}
// ============================================================================
// INTERNAL: activateWatch
// ============================================================================
// activateWatch is called at the end of any successful file-based load.
// It is a no-op when Options.Watch is false or when the same file is already
// being watched. When a different file was previously watched, the old watcher
// is cleanly torn down before the new one starts.
func (tree *figTree) activateWatch(path string) {
if !tree.watchEnabled {
return
}
absPath, err := filepath.Abs(path)
if err != nil {
tree.handleWatchError(fmt.Errorf(
"figtree watcher: could not resolve absolute path for %q: %w", path, err,
))
return
}
tree.watcher.mu.Lock()
defer tree.watcher.mu.Unlock()
// Already watching this exact file — nothing to do
if tree.watcher.active && tree.watcher.watchedPath == absPath {
return
}
// Tear down any prior watcher cleanly before starting a new one
tree.teardownWatchLocked()
fsw, err := fsnotify.NewWatcher()
if err != nil {
tree.handleWatchError(fmt.Errorf(
"figtree watcher: failed to create fsnotify watcher: %w", err,
))
return
}
if err := fsw.Add(absPath); err != nil {
_ = fsw.Close()
tree.handleWatchError(fmt.Errorf(
"figtree watcher: failed to add %q to watcher: %w", absPath, err,
))
return
}
stopCh := make(chan struct{})
tree.watcher.fsw = fsw
tree.watcher.watchedPath = absPath
tree.watcher.active = true
tree.watcher.stopCh = stopCh
go tree.watchLoop(fsw, absPath, stopCh)
}
// ============================================================================
// INTERNAL: watchLoop
// ============================================================================
// watchLoop runs in its own goroutine for the lifetime of the watch.
// It debounces rapid filesystem events — editors like vim and many IDEs
// write files in multiple flushes; without debouncing a single save would
// trigger several redundant reloads.
func (tree *figTree) watchLoop(fsw *fsnotify.Watcher, path string, stopCh <-chan struct{}) {
debounce := tree.watchDebounce
if debounce <= 0 {
debounce = WatchDebounce
}
var (
timerMu sync.Mutex
debounceTimer *time.Timer
)
scheduleReload := func() {
timerMu.Lock()
defer timerMu.Unlock()
if debounceTimer != nil {
// Push the deadline forward — still one reload after activity stops
debounceTimer.Reset(debounce)
return
}
debounceTimer = time.AfterFunc(debounce, func() {
timerMu.Lock()
debounceTimer = nil
timerMu.Unlock()
tree.reloadFromWatch(path)
})
}
cancelTimer := func() {
timerMu.Lock()
defer timerMu.Unlock()
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
}
for {
select {
case <-stopCh:
cancelTimer()
return
case event, ok := <-fsw.Events:
if !ok {
// fsnotify closed its own channel — watcher is gone
cancelTimer()
return
}
switch {
case event.Has(fsnotify.Write):
// Standard in-place save
scheduleReload()
case event.Has(fsnotify.Rename):
// Atomic-write editors (vim, IntelliJ) rename a temp file
// over the target; re-add the path so we keep watching it
// then schedule a reload
_ = fsw.Add(path)
scheduleReload()
case event.Has(fsnotify.Remove):
// File was deleted — surface error but stay alive in case it
// is recreated (e.g. a deployment pipeline replacing the file)
tree.handleWatchError(fmt.Errorf(
"figtree watcher: watched file %q was removed", path,
))
}
case err, ok := <-fsw.Errors:
if !ok {
cancelTimer()
return
}
tree.handleWatchError(fmt.Errorf(
"figtree watcher: fsnotify error on %q: %w", path, err,
))
}
}
}
// ============================================================================
// INTERNAL: reloadFromWatch
// ============================================================================
// reloadFromWatch re-parses the watched file and feeds changed values back
// through the normal Store() pipeline. This means:
// - RulePreventChange rejects a file-driven update the same as a manual one
// - RulePanicOnChange panics on a file-driven update the same as a manual one
// - WithValidator() runs on every reloaded value
// - CallbackAfterChange fires for every key whose value changed
// - Mutations() channel receives every file-driven change automatically
//
// Keys present in the file but not registered in the tree are silently skipped,
// matching the behaviour of the initial Load/ParseFile call.
func (tree *figTree) reloadFromWatch(path string) {
incoming, err := tree.parseFileToRawMap(path)
if err != nil {
tree.handleWatchError(fmt.Errorf(
"figtree watcher: reload parse error on %q: %w", path, err,
))
return
}
for key, rawValue := range incoming {
// Resolve alias to canonical name if needed
canonicalKey := key
tree.mu.RLock()
for alias, name := range tree.aliases {
if strings.EqualFold(alias, key) {
canonicalKey = name
break
}
}
tree.mu.RUnlock()
fig := tree.Fig(canonicalKey)
if fig == nil {
// Not registered — skip, same as initial load behaviour
continue
}
// Use the existing Set path on the live Value so validators,
// callbacks, rules, and mutation tracking all fire naturally
value, fromErr := tree.from(canonicalKey)
if fromErr != nil || value == nil {
tree.handleWatchError(fmt.Errorf(
"figtree watcher: could not retrieve fig %q during reload: %w",
canonicalKey, fromErr,
))
continue
}
setErr := value.Set(fmt.Sprintf("%v", rawValue))
if setErr != nil {
tree.handleWatchError(fmt.Errorf(
"figtree watcher: could not apply reloaded value for key %q: %w",
canonicalKey, setErr,
))
continue
}
tree.values.Store(canonicalKey, value)
}
}
// ============================================================================
// INTERNAL: parseFileToRawMap
// ============================================================================
// parseFileToRawMap opens the file at path, detects its format from the
// extension, and returns a flat map[string]interface{} of raw values.
// It uses the same extension routing as loadFile() so behaviour is consistent.
func (tree *figTree) parseFileToRawMap(path string) (map[string]interface{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("could not open %q: %w", path, err)
}
defer f.Close()
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".yaml", ".yml":
return parseYAMLReader(f)
case ".json":
return parseJSONReader(f)
case ".ini":
return parseINIReader(f)
default:
return nil, fmt.Errorf("unsupported config file extension %q", ext)
}
}
// ============================================================================
// INTERNAL: teardownWatchLocked
// ============================================================================
// teardownWatchLocked stops the watch goroutine and closes the fsnotify
// watcher. Caller MUST hold tree.watcher.mu before calling this.
func (tree *figTree) teardownWatchLocked() {
if !tree.watcher.active {
return
}
// Signal the watchLoop goroutine to exit
close(tree.watcher.stopCh)
// Close the fsnotify watcher — this also closes fsw.Events and fsw.Errors,
// causing the goroutine's channel selects to drain and exit cleanly
_ = tree.watcher.fsw.Close()
tree.watcher.active = false
tree.watcher.watchedPath = ""
tree.watcher.fsw = nil
tree.watcher.stopCh = nil
}
// ============================================================================
// INTERNAL: handleWatchError
// ============================================================================
// handleWatchError routes watch errors to the user-supplied OnWatchError hook
// when set, otherwise falls back to log.Println. This keeps watch errors
// visible without forcing a fatal or panic on what are often transient issues.
func (tree *figTree) handleWatchError(err error) {
if tree.onWatchError != nil {
tree.onWatchError(err)
return
}
log.Println(err)
}
Update loading.go
package figtree
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
check "github.com/andreimerlescu/checkfs"
"github.com/andreimerlescu/checkfs/file"
)
// Reload will readEnv on each flag in the configurable package
func (tree *figTree) Reload() error {
tree.readEnv()
return tree.validateAll()
}
func (tree *figTree) preLoadOrParse() error {
tree.mu.RLock()
defer tree.mu.RUnlock()
for name, fig := range tree.figs {
value, err := tree.from(name)
if err != nil {
return err
}
if value.Err != nil {
return value.Err
}
if fig.Error != nil {
return fig.Error
}
}
return tree.checkFigErrors()
}
// Load uses the EnvironmentKey and the DefaultJSONFile, DefaultYAMLFile, and
// DefaultINIFile to run ParseFile if it exists. When Options.Watch is true,
// the first config file that successfully loads is automatically watched for
// changes via fsnotify.
func (tree *figTree) Load() (err error) {
preloadErr := tree.preLoadOrParse()
if preloadErr != nil {
return preloadErr
}
if !tree.HasRule(RuleNoFlags) {
tree.activateFlagSet()
args := os.Args[1:]
if tree.filterTests {
args = filterTestFlags(args)
}
err = tree.flagSet.Parse(args)
if err != nil {
err2 := tree.checkFigErrors()
if err2 != nil {
err = errors.Join(err, err2)
}
return ErrLoadFailure{"flags", err}
}
err = tree.loadFlagSet()
if err != nil {
return err
}
}
first := ""
if !tree.HasRule(RuleNoEnv) {
first = os.Getenv(EnvironmentKey)
}
files := []string{
first,
tree.ConfigFilePath,
ConfigFilePath,
filepath.Join(".", DefaultJSONFile),
filepath.Join(".", DefaultINIFile),
}
loadedPath := "" // track which file actually loaded
for i := 0; i < len(files); i++ {
f := files[i]
if f == "" {
continue
}
if err := check.File(f, file.Options{Exists: true}); err == nil {
if err := tree.loadFile(f); err != nil {
return ErrLoadFailure{f, err}
}
if loadedPath == "" {
loadedPath = f // record the first file that succeeded
}
}
}
tree.readEnv()
err = tree.checkFigErrors()
if err != nil {
return fmt.Errorf("checkFigErrors() threw err: %w", err)
}
if err = tree.validateAll(); err != nil {
return err
}
// Activate fsnotify watch on the resolved config file — no-op when
// Options.Watch is false or when no file was found
if loadedPath != "" {
tree.activateWatch(loadedPath) // ADDED
}
return nil
}
// LoadFile accepts a path and uses it to populate the figTree.
// When Options.Watch is true and the file exists, it is automatically
// watched for changes via fsnotify after a successful load.
func (tree *figTree) LoadFile(path string) (err error) {
preloadErr := tree.preLoadOrParse()
if preloadErr != nil {
return preloadErr
}
if !tree.HasRule(RuleNoFlags) {
tree.activateFlagSet()
args := os.Args[1:]
if tree.filterTests {
args = filterTestFlags(args)
}
err = tree.flagSet.Parse(args)
if err != nil {
err2 := tree.checkFigErrors()
if err2 != nil {
err = errors.Join(err, err2)
}
return err
}
}
var loadErr error
if loadErr = check.File(path, file.Options{Exists: true}); loadErr == nil {
if err2 := tree.loadFile(path); err2 != nil {
return ErrLoadFailure{path, err2}
}
tree.readEnv()
err3 := tree.loadFlagSet()
if err3 != nil {
return err3
}
err4 := tree.validateAll()
if err4 != nil {
return ErrValidationFailure{err4}
}
tree.activateWatch(path) // ADDED — file confirmed to exist and loaded
return nil
}
err3 := tree.loadFlagSet()
if err3 != nil {
return err3
}
tree.readEnv()
err4 := tree.checkFigErrors()
if err4 != nil {
return fmt.Errorf("failed to checkFigErrors: %w", err4)
}
err5 := tree.validateAll()
if err5 != nil {
return ErrValidationFailure{err5}
}
return ErrLoadFailure{path, loadErr}
// Note: no activateWatch here — we only watch files that actually loaded
}
func (tree *figTree) loadFlagSet() (e error) {
defer func() {
if e != nil {
_, _ = fmt.Fprintf(os.Stderr, "loadFlagSet() err: %s", e.Error())
}
}()
tree.flagSet.VisitAll(func(f *flag.Flag) {
flagName := f.Name
for alias, name := range tree.aliases {
if strings.EqualFold(alias, f.Name) {
flagName = name
}
}
value, err := tree.from(flagName)
if err != nil || value == nil {
e = ErrLoadFailure{flagName, err}
return
}
switch value.Mutagensis {
case tMap:
merged := value.Flesh().ToMap()
withered := tree.withered[flagName]
witheredValue := withered.Value.Flesh().ToMap()
flagged, err := toStringMap(f.Value)
if err != nil {
e = ErrLoadFailure{flagName, err}
return
}
result := make(map[string]string)
if PolicyMapAppend {
for k, v := range witheredValue {
result[k] = v
}
}
for k, v := range merged {
result[k] = v
}
for k, v := range flagged {
result[k] = v
}
err = value.Assign(result)
if err != nil {
e = ErrLoadFailure{flagName, err}
return
}
case tList:
merged, err := toStringSlice(value.Value)
if err != nil {
e = ErrLoadFailure{flagName, err}
return
}
flagged, err := toStringSlice(f.Value)
if err != nil {
e = ErrLoadFailure{flagName, err}
return
}
unique := make(map[string]bool)
for _, v := range merged {
unique[v] = true
}
for _, v := range flagged {
unique[v] = true
}
var newValue []string
for k := range unique {
newValue = append(newValue, k)
}
err = value.Assign(newValue)
if err != nil {
e = ErrLoadFailure{flagName, err}
return
}
default:
v := f.Value.String()
err := value.Set(v)
if err != nil {
e = ErrLoadFailure{flagName, fmt.Errorf("failed to value.Set(%s): %w", f.Value.String(), err)}
return
}
}
tree.values.Store(flagName, value)
})
return nil
}
Update parsing.go
func (tree *figTree) ParseFile(filename string) error {
// ... your existing ParseFile body unchanged ...
// At the point where you would have returned nil after a successful parse:
tree.activateWatch(filename) // ADDED
return nil
}
Developer Experience
Watcher
watcher.goModify
loading.goModify Fruit Interface
Dependency
Update
go.modwith:require ( github.com/fsnotify/fsnotify v1.7.0 )Update
vars.goUpdate
types.goUpdate
figtree.goAdd
watcher.goUpdate
loading.goUpdate
parsing.go